[PM-18067] Consolidate item name fields into ItemHeader (#4766)

This commit is contained in:
Patrick Honkonen
2025-02-27 17:31:39 +00:00
committed by GitHub
24 changed files with 1770 additions and 339 deletions

View File

@@ -108,7 +108,7 @@ fun BitwardenTextField(
label: String?,
value: String,
onValueChange: (String) -> Unit,
cardStyle: CardStyle,
cardStyle: CardStyle?,
modifier: Modifier = Modifier,
tooltip: TooltipData? = null,
placeholder: String? = null,
@@ -207,7 +207,7 @@ fun BitwardenTextField(
value: String,
onValueChange: (String) -> Unit,
supportingContent: (@Composable ColumnScope.() -> Unit)?,
cardStyle: CardStyle,
cardStyle: CardStyle?,
modifier: Modifier = Modifier,
tooltip: TooltipData? = null,
supportingContentPadding: PaddingValues = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
@@ -378,7 +378,7 @@ fun BitwardenTextField(
content = content,
)
}
?: Spacer(modifier = Modifier.height(height = 6.dp))
?: Spacer(modifier = Modifier.height(height = cardStyle?.let { 6.dp } ?: 0.dp))
}
val filteredAutoCompleteList = autoCompleteOptions
.filter { option ->

View File

@@ -1,7 +1,11 @@
package com.x8bit.bitwarden.ui.platform.components.header
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.AnimationConstants
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
@@ -12,6 +16,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@@ -21,17 +26,25 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Reusable header element that is clickable for expanding or collapsing content.
*
* @param collapsedText Text to display when the content is collapsed.
* @param expandedText Text to display when the content is expanded.
* @param showExpansionIndicator Whether to show an indicator to expand or collapse the content.
*/
@Composable
fun BitwardenExpandingHeader(
isExpanded: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
title: String = stringResource(id = R.string.additional_options),
collapsedText: String = stringResource(id = R.string.additional_options),
expandedText: String = collapsedText,
showExpansionIndicator: Boolean = true,
shape: Shape = BitwardenTheme.shapes.content,
insets: PaddingValues = PaddingValues(top = 16.dp, bottom = 8.dp),
) {
Row(
modifier = modifier
.clip(shape = BitwardenTheme.shapes.content)
.clip(shape = shape)
.clickable(
onClickLabel = stringResource(
id = if (isExpanded) R.string.options_expanded else R.string.options_collapsed,
@@ -39,26 +52,51 @@ fun BitwardenExpandingHeader(
onClick = onClick,
)
.minimumInteractiveComponentSize()
.padding(top = 16.dp, bottom = 8.dp)
.padding(horizontal = 16.dp)
.padding(paddingValues = insets)
.semantics(mergeDescendants = true) {},
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
color = BitwardenTheme.colorScheme.text.interaction,
style = BitwardenTheme.typography.labelLarge,
modifier = Modifier.padding(end = 8.dp),
)
val iconRotationDegrees = animateFloatAsState(
targetValue = if (isExpanded) 0f else 180f,
label = "expanderIconRotationAnimation",
)
Icon(
painter = rememberVectorPainter(id = R.drawable.ic_chevron_up_small),
contentDescription = null,
tint = BitwardenTheme.colorScheme.icon.secondary,
modifier = Modifier.rotate(degrees = iconRotationDegrees.value),
)
Crossfade(
targetState = isExpanded,
label = "BitwardenExpandingHeaderTitle_animation",
// Make the animation shorter when the text is the same to avoid crossfading the same
// text.
animationSpec = tween(
durationMillis = if (expandedText != collapsedText) {
AnimationConstants.DefaultDurationMillis
} else {
0
},
),
) { expanded ->
if (expanded) {
Text(
text = expandedText,
color = BitwardenTheme.colorScheme.text.interaction,
style = BitwardenTheme.typography.labelLarge,
modifier = Modifier.padding(end = 8.dp),
)
} else {
Text(
text = collapsedText,
color = BitwardenTheme.colorScheme.text.interaction,
style = BitwardenTheme.typography.labelLarge,
modifier = Modifier.padding(end = 8.dp),
)
}
}
if (showExpansionIndicator) {
val iconRotationDegrees = animateFloatAsState(
targetValue = if (isExpanded) 0f else 180f,
label = "expanderIconRotationAnimation",
)
Icon(
painter = rememberVectorPainter(id = R.drawable.ic_chevron_up_small),
contentDescription = null,
tint = BitwardenTheme.colorScheme.icon.secondary,
modifier = Modifier.rotate(degrees = iconRotationDegrees.value),
)
}
}
}

View File

@@ -17,6 +17,7 @@ data class BitwardenColorScheme(
val toggleButton: ToggleButtonColors,
val sliderButton: SliderButtonColors,
val status: StatusColors,
val illustration: IllustrationColors,
) {
/**
* Defines all the text colors for the app.
@@ -124,4 +125,13 @@ data class BitwardenColorScheme(
val weak2: Color,
val error: Color,
)
/**
* Defines all the illustration colors for the app.
*/
@Immutable
data class IllustrationColors(
val outline: Color,
val backgroundPrimary: Color,
)
}

View File

@@ -69,6 +69,10 @@ val darkBitwardenColorScheme: BitwardenColorScheme = BitwardenColorScheme(
weak2 = PrimitiveColors.yellow200,
error = PrimitiveColors.red200,
),
illustration = BitwardenColorScheme.IllustrationColors(
outline = PrimitiveColors.blue500,
backgroundPrimary = PrimitiveColors.blue200,
),
)
/**
@@ -137,6 +141,10 @@ val lightBitwardenColorScheme: BitwardenColorScheme = BitwardenColorScheme(
weak2 = PrimitiveColors.yellow300,
error = PrimitiveColors.red300,
),
illustration = BitwardenColorScheme.IllustrationColors(
outline = PrimitiveColors.blue700,
backgroundPrimary = PrimitiveColors.blue100,
),
)
/**
@@ -211,6 +219,10 @@ fun dynamicBitwardenColorScheme(
weak2 = defaultTheme.status.weak2,
error = defaultTheme.status.error,
),
illustration = BitwardenColorScheme.IllustrationColors(
outline = materialColorScheme.tertiaryContainer,
backgroundPrimary = materialColorScheme.onTertiaryContainer,
),
)
}

View File

@@ -22,4 +22,5 @@ data class BitwardenShapes(
val progressIndicator: CornerBasedShape,
val segmentedControl: CornerBasedShape,
val snackbar: CornerBasedShape,
val favicon: CornerBasedShape,
)

View File

@@ -22,4 +22,5 @@ val bitwardenShapes: BitwardenShapes = BitwardenShapes(
progressIndicator = CircleShape,
segmentedControl = CircleShape,
snackbar = RoundedCornerShape(size = 8.dp),
favicon = CircleShape,
)

View File

@@ -9,6 +9,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -22,8 +26,8 @@ import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.vault.feature.item.component.CustomField
import com.x8bit.bitwarden.ui.vault.feature.item.component.ItemNameField
import com.x8bit.bitwarden.ui.vault.feature.item.component.VaultItemUpdateText
import com.x8bit.bitwarden.ui.vault.feature.item.component.itemHeader
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCardItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
@@ -41,21 +45,26 @@ fun VaultItemCardContent(
vaultCardItemTypeHandlers: VaultCardItemTypeHandlers,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
var isExpanded by rememberSaveable { mutableStateOf(value = false) }
LazyColumn(modifier = modifier.fillMaxWidth()) {
item {
Spacer(Modifier.height(height = 12.dp))
}
itemHeader(
value = commonState.name,
isFavorite = commonState.favorite,
iconData = commonState.iconData,
relatedLocations = commonState.relatedLocations,
iconTestTag = "CardItemNameIcon",
textFieldTestTag = "CardItemNameEntry",
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
)
item {
Spacer(modifier = Modifier.height(height = 12.dp))
ItemNameField(
value = commonState.name,
isFavorite = commonState.favorite,
textFieldTestTag = "CardItemNameEntry",
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
cardState.cardholderName?.let { cardholderName ->
item {
item(key = "cardholderName") {
BitwardenTextField(
label = stringResource(id = R.string.cardholder_name),
value = cardholderName,
@@ -71,12 +80,13 @@ fun VaultItemCardContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
cardState.number?.let { numberData ->
item {
item(key = "cardNumber") {
BitwardenPasswordField(
label = stringResource(id = R.string.number),
value = numberData.number,
@@ -103,13 +113,14 @@ fun VaultItemCardContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
if (cardState.brand != null && cardState.brand != VaultCardBrand.SELECT) {
item {
item(key = "cardBrand") {
BitwardenTextField(
label = stringResource(id = R.string.brand),
value = cardState.brand.shortName(),
@@ -125,13 +136,14 @@ fun VaultItemCardContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
cardState.expiration?.let { expiration ->
item {
item(key = "expiration") {
BitwardenTextField(
label = stringResource(id = R.string.expiration),
value = expiration,
@@ -147,13 +159,14 @@ fun VaultItemCardContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
cardState.securityCode?.let { securityCodeData ->
item {
item(key = "securityCode") {
BitwardenPasswordField(
label = stringResource(id = R.string.security_code),
value = securityCodeData.code,
@@ -165,7 +178,9 @@ fun VaultItemCardContent(
actions = {
BitwardenStandardIconButton(
vectorIconRes = R.drawable.ic_copy,
contentDescription = stringResource(id = R.string.copy_security_code),
contentDescription = stringResource(
id = R.string.copy_security_code,
),
onClick = vaultCardItemTypeHandlers.onCopySecurityCodeClick,
modifier = Modifier.testTag(tag = "CardCopySecurityCodeButton"),
)
@@ -180,20 +195,22 @@ fun VaultItemCardContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
commonState.notes?.let { notes ->
item {
item(key = "notes") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.additional_options),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
@@ -214,23 +231,28 @@ fun VaultItemCardContent(
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
commonState.customFields.takeUnless { it.isEmpty() }?.let { customFields ->
item {
item(key = "customFieldsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.custom_fields),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
}
items(customFields) { customField ->
itemsIndexed(
items = customFields,
key = { index, _ -> "customField_$index" },
) { _, customField ->
Spacer(modifier = Modifier.height(height = 8.dp))
CustomField(
customField = customField,
@@ -240,29 +262,35 @@ fun VaultItemCardContent(
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
commonState.attachments.takeUnless { it?.isEmpty() == true }?.let { attachments ->
item {
item(key = "attachmentsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.attachments),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
itemsIndexed(attachments) { index, attachmentItem ->
itemsIndexed(
items = attachments,
key = { index, _ -> "attachment_$index" },
) { index, attachmentItem ->
AttachmentItemContent(
modifier = Modifier
.testTag("CipherAttachment")
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
attachmentItem = attachmentItem,
onAttachmentDownloadClick = vaultCommonItemTypeHandlers
.onAttachmentDownloadClick,
@@ -271,7 +299,7 @@ fun VaultItemCardContent(
}
}
item {
item(key = "lastUpdated") {
Spacer(modifier = Modifier.height(height = 16.dp))
VaultItemUpdateText(
header = "${stringResource(id = R.string.date_updated)}: ",
@@ -279,7 +307,8 @@ fun VaultItemCardContent(
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 12.dp),
.padding(horizontal = 12.dp)
.animateItem(),
)
}
item {

View File

@@ -9,6 +9,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -21,8 +25,8 @@ import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.vault.feature.item.component.CustomField
import com.x8bit.bitwarden.ui.vault.feature.item.component.ItemNameField
import com.x8bit.bitwarden.ui.vault.feature.item.component.VaultItemUpdateText
import com.x8bit.bitwarden.ui.vault.feature.item.component.itemHeader
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultIdentityItemTypeHandlers
@@ -38,21 +42,28 @@ fun VaultItemIdentityContent(
vaultIdentityItemTypeHandlers: VaultIdentityItemTypeHandlers,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
var isExpanded by rememberSaveable { mutableStateOf(value = false) }
LazyColumn(
modifier = modifier.fillMaxWidth(),
) {
item {
Spacer(Modifier.height(height = 12.dp))
}
itemHeader(
value = commonState.name,
isFavorite = commonState.favorite,
iconData = commonState.iconData,
relatedLocations = commonState.relatedLocations,
iconTestTag = "IdentityItemNameIcon",
textFieldTestTag = "IdentityItemNameEntry",
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
)
item {
Spacer(modifier = Modifier.height(height = 12.dp))
ItemNameField(
value = commonState.name,
isFavorite = commonState.favorite,
textFieldTestTag = "ItemNameEntry",
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
identityState.identityName?.let { identityName ->
item {
item(key = "identityName") {
IdentityCopyField(
label = stringResource(id = R.string.identity_name),
value = identityName,
@@ -68,12 +79,13 @@ fun VaultItemIdentityContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
identityState.username?.let { username ->
item {
item(key = "username") {
IdentityCopyField(
label = stringResource(id = R.string.username),
value = username,
@@ -89,12 +101,13 @@ fun VaultItemIdentityContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
identityState.company?.let { company ->
item {
item(key = "company") {
IdentityCopyField(
label = stringResource(id = R.string.company),
value = company,
@@ -110,12 +123,13 @@ fun VaultItemIdentityContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
identityState.ssn?.let { ssn ->
item {
item(key = "ssn") {
IdentityCopyField(
label = stringResource(id = R.string.ssn),
value = ssn,
@@ -131,12 +145,13 @@ fun VaultItemIdentityContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
identityState.passportNumber?.let { passportNumber ->
item {
item(key = "passportNumber") {
IdentityCopyField(
label = stringResource(id = R.string.passport_number),
value = passportNumber,
@@ -152,12 +167,13 @@ fun VaultItemIdentityContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
identityState.licenseNumber?.let { licenseNumber ->
item {
item(key = "licenseNumber") {
IdentityCopyField(
label = stringResource(id = R.string.license_number),
value = licenseNumber,
@@ -173,12 +189,13 @@ fun VaultItemIdentityContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
identityState.email?.let { email ->
item {
item(key = "email") {
IdentityCopyField(
label = stringResource(id = R.string.email),
value = email,
@@ -194,12 +211,13 @@ fun VaultItemIdentityContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
identityState.phone?.let { phone ->
item {
item(key = "phone") {
IdentityCopyField(
label = stringResource(id = R.string.phone),
value = phone,
@@ -215,12 +233,13 @@ fun VaultItemIdentityContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
identityState.address?.let { address ->
item {
item(key = "address") {
IdentityCopyField(
label = stringResource(id = R.string.address),
value = address,
@@ -236,19 +255,21 @@ fun VaultItemIdentityContent(
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
commonState.notes?.let { notes ->
item {
item(key = "notes") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.additional_options),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(modifier = Modifier.height(8.dp))
IdentityCopyField(
@@ -261,23 +282,28 @@ fun VaultItemIdentityContent(
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
commonState.customFields.takeUnless { it.isEmpty() }?.let { customFields ->
item {
item(key = "customFieldsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.custom_fields),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
}
items(customFields) { customField ->
items(
items = customFields,
key = { "customField_$it" },
) { customField ->
Spacer(modifier = Modifier.height(height = 8.dp))
CustomField(
customField = customField,
@@ -287,29 +313,35 @@ fun VaultItemIdentityContent(
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
commonState.attachments.takeUnless { it?.isEmpty() == true }?.let { attachments ->
item {
item(key = "attachmentsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.attachments),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
itemsIndexed(attachments) { index, attachmentItem ->
itemsIndexed(
items = attachments,
key = { index, _ -> "attachment_$index" },
) { index, attachmentItem ->
AttachmentItemContent(
modifier = Modifier
.testTag("CipherAttachment")
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
attachmentItem = attachmentItem,
onAttachmentDownloadClick = vaultCommonItemTypeHandlers
.onAttachmentDownloadClick,
@@ -318,7 +350,7 @@ fun VaultItemIdentityContent(
}
}
item {
item(key = "lastUpdated") {
Spacer(modifier = Modifier.height(height = 16.dp))
VaultItemUpdateText(
header = "${stringResource(id = R.string.date_updated)}: ",
@@ -326,7 +358,8 @@ fun VaultItemIdentityContent(
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 12.dp),
.padding(horizontal = 12.dp)
.animateItem(),
)
}
item {

View File

@@ -8,9 +8,12 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -30,8 +33,8 @@ import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenHyperTextLink
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.item.component.CustomField
import com.x8bit.bitwarden.ui.vault.feature.item.component.ItemNameField
import com.x8bit.bitwarden.ui.vault.feature.item.component.VaultItemUpdateText
import com.x8bit.bitwarden.ui.vault.feature.item.component.itemHeader
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultLoginItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
@@ -50,47 +53,40 @@ fun VaultItemLoginContent(
vaultLoginItemTypeHandlers: VaultLoginItemTypeHandlers,
modifier: Modifier = Modifier,
) {
var isExpanded by rememberSaveable { mutableStateOf(value = false) }
LazyColumn(
modifier = modifier,
modifier = modifier.fillMaxWidth(),
) {
item {
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.item_details),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
Spacer(Modifier.height(height = 12.dp))
}
item {
ItemNameField(
value = commonState.name,
isFavorite = commonState.favorite,
textFieldTestTag = "LoginItemNameEntry",
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
itemHeader(
value = commonState.name,
isFavorite = commonState.favorite,
iconData = commonState.iconData,
relatedLocations = commonState.relatedLocations,
iconTestTag = "LoginItemNameIcon",
textFieldTestTag = "LoginItemNameEntry",
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
)
if (loginItemState.hasLoginCredentials) {
item {
item(key = "loginCredentialsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.login_credentials),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
}
loginItemState.username?.let { username ->
item {
item(key = "username") {
UsernameField(
username = username,
onCopyUsernameClick = vaultLoginItemTypeHandlers.onCopyUsernameClick,
@@ -99,14 +95,15 @@ fun VaultItemLoginContent(
?.let { CardStyle.Top(dividerPadding = 0.dp) }
?: CardStyle.Full,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.standardHorizontalMargin(),
.animateItem(),
)
}
}
loginItemState.passwordData?.let { passwordData ->
item {
item(key = "passwordData") {
PasswordField(
passwordData = passwordData,
onShowPasswordClick = vaultLoginItemTypeHandlers.onShowPasswordClick,
@@ -117,26 +114,28 @@ fun VaultItemLoginContent(
?.let { CardStyle.Bottom }
?: CardStyle.Full,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.standardHorizontalMargin(),
.animateItem(),
)
}
}
loginItemState.fido2CredentialCreationDateText?.let { creationDate ->
item {
item(key = "creationDate") {
Spacer(modifier = Modifier.height(8.dp))
Fido2CredentialField(
creationDate = creationDate(),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.standardHorizontalMargin(),
.animateItem(),
)
}
}
loginItemState.totpCodeItemData?.let { totpCodeItemData ->
item {
item(key = "totpCode") {
Spacer(modifier = Modifier.height(8.dp))
TotpField(
totpCodeItemData = totpCodeItemData,
@@ -145,71 +144,83 @@ fun VaultItemLoginContent(
onAuthenticatorHelpToolTipClick = vaultLoginItemTypeHandlers
.onAuthenticatorHelpToolTipClick,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.standardHorizontalMargin(),
.animateItem(),
)
}
}
loginItemState.uris.takeUnless { it.isEmpty() }?.let { uris ->
item {
item(key = "urisHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.autofill_options),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
itemsIndexed(uris) { index, uriData ->
itemsIndexed(
items = uris,
key = { index, _ -> "uri_$index" },
) { index, uriData ->
UriField(
uriData = uriData,
onCopyUriClick = vaultLoginItemTypeHandlers.onCopyUriClick,
onLaunchUriClick = vaultLoginItemTypeHandlers.onLaunchUriClick,
cardStyle = uris.toListItemCardStyle(index = index, dividerPadding = 0.dp),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.standardHorizontalMargin(),
.animateItem(),
)
}
}
commonState.notes?.let { notes ->
item {
item(key = "notes") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.additional_options),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.fillMaxWidth()
.animateItem(),
)
Spacer(modifier = Modifier.height(8.dp))
NotesField(
notes = notes,
onCopyAction = vaultCommonItemTypeHandlers.onCopyNotesClick,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.padding(horizontal = 16.dp),
.animateItem(),
)
}
}
commonState.customFields.takeUnless { it.isEmpty() }?.let { customFields ->
item {
item(key = "customFieldsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.custom_fields),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
}
items(customFields) { customField ->
itemsIndexed(
items = customFields,
key = { index, _ -> "customField_$index" },
) { _, customField ->
Spacer(modifier = Modifier.height(height = 8.dp))
CustomField(
customField = customField,
@@ -218,29 +229,35 @@ fun VaultItemLoginContent(
onShowHiddenFieldClick = vaultCommonItemTypeHandlers.onShowHiddenFieldClick,
cardStyle = CardStyle.Full,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.standardHorizontalMargin(),
.animateItem(),
)
}
}
commonState.attachments.takeUnless { it?.isEmpty() == true }?.let { attachments ->
item {
item(key = "attachmentsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.attachments),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.fillMaxWidth()
.animateItem(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
itemsIndexed(attachments) { index, attachmentItem ->
itemsIndexed(
items = attachments,
key = { index, _ -> "attachment_$index" },
) { index, attachmentItem ->
AttachmentItemContent(
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.standardHorizontalMargin(),
.animateItem(),
attachmentItem = attachmentItem,
cardStyle = attachments.toListItemCardStyle(index = index),
onAttachmentDownloadClick = vaultCommonItemTypeHandlers
@@ -249,20 +266,21 @@ fun VaultItemLoginContent(
}
}
item {
item(key = "lastUpdated") {
Spacer(modifier = Modifier.height(16.dp))
VaultItemUpdateText(
header = "${stringResource(id = R.string.date_updated)}: ",
text = commonState.lastUpdated,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 12.dp),
.padding(horizontal = 12.dp)
.fillMaxWidth()
.animateItem(),
)
}
loginItemState.passwordRevisionDate?.let { revisionDate ->
item {
item(key = "revisionDate") {
Spacer(modifier = Modifier.height(height = 4.dp))
VaultItemUpdateText(
header = "${stringResource(id = R.string.date_password_updated)}: ",
@@ -270,13 +288,14 @@ fun VaultItemLoginContent(
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 12.dp),
.padding(horizontal = 12.dp)
.animateItem(),
)
}
}
loginItemState.passwordHistoryCount?.let { passwordHistoryCount ->
item {
item(key = "passwordHistoryCount") {
Spacer(modifier = Modifier.height(height = 4.dp))
BitwardenHyperTextLink(
annotatedResId = R.string.password_history_count,
@@ -288,7 +307,8 @@ fun VaultItemLoginContent(
modifier = Modifier
.wrapContentWidth()
.standardHorizontalMargin()
.padding(horizontal = 12.dp),
.padding(horizontal = 12.dp)
.animateItem(),
)
}
}

View File

@@ -7,10 +7,13 @@ 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.foundation.lazy.itemsIndexed
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -25,7 +28,7 @@ import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.item.component.CustomField
import com.x8bit.bitwarden.ui.vault.feature.item.component.ItemNameField
import com.x8bit.bitwarden.ui.vault.feature.item.component.itemHeader
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
/**
@@ -38,21 +41,26 @@ fun VaultItemSecureNoteContent(
vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
var isExpanded by rememberSaveable { mutableStateOf(value = false) }
LazyColumn(modifier = modifier.fillMaxWidth()) {
item {
Spacer(modifier = Modifier.height(height = 12.dp))
ItemNameField(
value = commonState.name,
isFavorite = commonState.favorite,
textFieldTestTag = "ItemNameEntry",
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 12.dp))
}
itemHeader(
value = commonState.name,
isFavorite = commonState.favorite,
iconData = commonState.iconData,
relatedLocations = commonState.relatedLocations,
iconTestTag = "SecureNoteItemNameIcon",
textFieldTestTag = "SecureNoteItemNameEntry",
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
)
item {
Spacer(modifier = Modifier.height(height = 8.dp))
}
commonState.notes?.let { notes ->
item {
item(key = "notes") {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.notes),
@@ -72,24 +80,29 @@ fun VaultItemSecureNoteContent(
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
commonState.customFields.takeUnless { it.isEmpty() }?.let { customFields ->
item {
item(key = "customFieldsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.custom_fields),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
}
items(customFields) { customField ->
itemsIndexed(
items = customFields,
key = { index, _ -> "customField_$index" },
) { _, customField ->
Spacer(modifier = Modifier.height(height = 8.dp))
CustomField(
customField = customField,
@@ -99,29 +112,35 @@ fun VaultItemSecureNoteContent(
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
commonState.attachments.takeUnless { it?.isEmpty() == true }?.let { attachments ->
item {
item(key = "attachmentsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.attachments),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
itemsIndexed(attachments) { index, attachmentItem ->
itemsIndexed(
items = attachments,
key = { index, _ -> "attachment_$index" },
) { index, attachmentItem ->
AttachmentItemContent(
modifier = Modifier
.testTag("CipherAttachment")
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
attachmentItem = attachmentItem,
onAttachmentDownloadClick = vaultCommonItemTypeHandlers
.onAttachmentDownloadClick,
@@ -130,14 +149,15 @@ fun VaultItemSecureNoteContent(
}
}
item {
item(key = "lastUpdated") {
Spacer(modifier = Modifier.height(height = 16.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 12.dp)
.semantics(mergeDescendants = true) { },
.semantics(mergeDescendants = true) { }
.animateItem(),
) {
Text(
text = "${stringResource(id = R.string.date_updated)}: ",

View File

@@ -6,9 +6,12 @@ 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.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -22,8 +25,8 @@ import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.vault.feature.item.component.CustomField
import com.x8bit.bitwarden.ui.vault.feature.item.component.ItemNameField
import com.x8bit.bitwarden.ui.vault.feature.item.component.VaultItemUpdateText
import com.x8bit.bitwarden.ui.vault.feature.item.component.itemHeader
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultSshKeyItemTypeHandlers
@@ -39,31 +42,22 @@ fun VaultItemSshKeyContent(
vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
var isExpanded by rememberSaveable { mutableStateOf(value = false) }
LazyColumn(modifier = modifier.fillMaxWidth()) {
item {
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.item_information),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
Spacer(Modifier.height(height = 12.dp))
}
item {
ItemNameField(
value = commonState.name,
isFavorite = commonState.favorite,
textFieldTestTag = "SshKeyItemNameEntry",
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
item {
itemHeader(
value = commonState.name,
isFavorite = commonState.favorite,
iconData = commonState.iconData,
relatedLocations = commonState.relatedLocations,
iconTestTag = "SshKeyItemNameIcon",
textFieldTestTag = "SshKeyItemNameEntry",
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
)
item(key = "publicKey") {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.public_key),
@@ -83,11 +77,12 @@ fun VaultItemSshKeyContent(
modifier = Modifier
.testTag("SshKeyItemPublicKeyEntry")
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
item {
item(key = "privateKey") {
BitwardenPasswordField(
label = stringResource(id = R.string.private_key),
value = sshKeyItemState.privateKey,
@@ -109,11 +104,12 @@ fun VaultItemSshKeyContent(
modifier = Modifier
.testTag("SshKeyItemPrivateKeyEntry")
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
item {
item(key = "fingerprint") {
BitwardenTextField(
label = stringResource(id = R.string.fingerprint),
value = sshKeyItemState.fingerprint,
@@ -132,19 +128,21 @@ fun VaultItemSshKeyContent(
modifier = Modifier
.testTag("SshKeyItemFingerprintEntry")
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
commonState.notes?.let { notes ->
item {
item(key = "notes") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.additional_options),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
@@ -165,23 +163,28 @@ fun VaultItemSshKeyContent(
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
commonState.customFields.takeUnless { it.isEmpty() }?.let { customFields ->
item {
item(key = "customFieldsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.custom_fields),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
}
items(customFields) { customField ->
itemsIndexed(
items = customFields,
key = { index, _ -> "customField_$index" },
) { _, customField ->
Spacer(modifier = Modifier.height(height = 8.dp))
CustomField(
customField = customField,
@@ -191,28 +194,34 @@ fun VaultItemSshKeyContent(
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
)
}
}
commonState.attachments.takeUnless { it?.isEmpty() == true }?.let { attachments ->
item {
item(key = "attachmentsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.attachments),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
itemsIndexed(attachments) { index, attachmentItem ->
itemsIndexed(
items = attachments,
key = { index, _ -> "attachment_$index" },
) { index, attachmentItem ->
AttachmentItemContent(
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
.standardHorizontalMargin()
.animateItem(),
attachmentItem = attachmentItem,
onAttachmentDownloadClick = vaultCommonItemTypeHandlers
.onAttachmentDownloadClick,
@@ -221,7 +230,7 @@ fun VaultItemSshKeyContent(
}
}
item {
item(key = "lastUpdated") {
Spacer(modifier = Modifier.height(height = 16.dp))
VaultItemUpdateText(
header = "${stringResource(id = R.string.date_updated)}: ",
@@ -230,6 +239,7 @@ fun VaultItemSshKeyContent(
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 12.dp)
.animateItem()
.testTag("SshKeyItemLastUpdated"),
)
}

View File

@@ -13,7 +13,10 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.data.platform.repository.util.mapNullable
import com.x8bit.bitwarden.data.vault.manager.FileManager
@@ -25,8 +28,10 @@ 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.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.util.persistentListOfNotNull
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.feature.item.model.VaultItemLocation
import com.x8bit.bitwarden.ui.vault.feature.item.model.VaultItemStateData
import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState
import com.x8bit.bitwarden.ui.vault.feature.util.canAssignToCollections
@@ -35,8 +40,11 @@ import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -51,7 +59,7 @@ private const val KEY_TEMP_ATTACHMENT = "tempAttachmentFile"
/**
* ViewModel responsible for handling user interactions in the vault item screen
*/
@Suppress("LargeClass", "TooManyFunctions")
@Suppress("LargeClass", "TooManyFunctions", "LongParameterList")
@HiltViewModel
class VaultItemViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
@@ -60,6 +68,8 @@ class VaultItemViewModel @Inject constructor(
private val vaultRepository: VaultRepository,
private val fileManager: FileManager,
private val organizationEventManager: OrganizationEventManager,
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
) : BaseViewModel<VaultItemState, VaultItemEvent, VaultItemAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: run {
@@ -69,6 +79,8 @@ class VaultItemViewModel @Inject constructor(
cipherType = args.cipherType,
viewState = VaultItemState.ViewState.Loading,
dialog = null,
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
)
},
) {
@@ -91,7 +103,8 @@ class VaultItemViewModel @Inject constructor(
authRepository.userStateFlow,
vaultRepository.getAuthCodeFlow(state.vaultItemId),
vaultRepository.collectionsStateFlow,
) { cipherViewState, userState, authCodeState, collectionsState ->
vaultRepository.foldersStateFlow,
) { cipherViewState, userState, authCodeState, collectionsState, folderState ->
val totpCodeData = authCodeState.data?.let {
TotpCodeItemData(
periodSeconds = it.periodSeconds,
@@ -106,35 +119,68 @@ class VaultItemViewModel @Inject constructor(
cipherViewState,
authCodeState,
collectionsState,
) { _, _, _ ->
folderState,
) { _, _, _, _ ->
// We are only combining the DataStates to know the overall state,
// we map it to the appropriate value below.
}
.mapNullable {
val canDelete = collectionsState
.data
val cipherView = cipherViewState.data
val canDelete = collectionsState.data
.hasDeletePermissionInAtLeastOneCollection(
collectionIds = cipherViewState.data?.collectionIds,
collectionIds = cipherView?.collectionIds,
)
val canAssignToCollections = collectionsState
.data
.canAssignToCollections(cipherViewState.data?.collectionIds)
val canAssignToCollections = collectionsState.data
.canAssignToCollections(cipherView?.collectionIds)
val canEdit = cipherViewState.data?.edit == true
val canEdit = cipherView?.edit == true
val organizationName = cipherView
?.organizationId
?.let { orgId ->
userState
?.activeAccount
?.organizations
?.firstOrNull { it.id == orgId }
?.name
}
val cipherCollections = cipherView
?.collectionIds
.orEmpty()
val collections = collectionsState.data
?.filter { cipherCollections.contains(it.id) }
?.map { it.name }
.orEmpty()
val folderName = cipherView
?.folderId
?.let { folderId ->
folderState.data?.firstOrNull { folder -> folderId == folder.id }
}
?.name
val relatedLocations = persistentListOfNotNull(
organizationName?.let { VaultItemLocation.Organization(it) },
*collections.map { VaultItemLocation.Collection(it) }.toTypedArray(),
folderName?.let { VaultItemLocation.Folder(it) },
)
VaultItemStateData(
cipher = cipherViewState.data,
cipher = cipherView,
totpCodeItemData = totpCodeData,
canDelete = canDelete,
canAssociateToCollections = canAssignToCollections,
canEdit = canEdit,
relatedLocations = relatedLocations,
)
},
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository.isIconLoadingDisabledFlow
.map { VaultItemAction.Internal.IsIconLoadingDisabledUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultItemAction) {
@@ -1046,6 +1092,10 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Internal.AttachmentFinishedSavingToDisk -> {
handleAttachmentFinishedSavingToDisk(action)
}
is VaultItemAction.Internal.IsIconLoadingDisabledUpdateReceive -> {
handleIsIconLoadingDisabledUpdateReceive(action)
}
}
}
@@ -1147,6 +1197,9 @@ class VaultItemViewModel @Inject constructor(
canDelete = this.data?.canDelete == true,
canAssignToCollections = this.data?.canAssociateToCollections == true,
canEdit = this.data?.canEdit == true,
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
relatedLocations = this.data?.relatedLocations.orEmpty().toImmutableList(),
)
?: VaultItemState.ViewState.Error(message = errorText)
@@ -1285,6 +1338,12 @@ class VaultItemViewModel @Inject constructor(
}
}
private fun handleIsIconLoadingDisabledUpdateReceive(
action: VaultItemAction.Internal.IsIconLoadingDisabledUpdateReceive,
) {
mutableStateFlow.update { it.copy(isIconLoadingDisabled = action.isDisabled) }
}
//endregion Internal Type Handlers
private fun updateDialogState(dialog: VaultItemState.DialogState?) {
@@ -1373,6 +1432,8 @@ data class VaultItemState(
val cipherType: VaultItemCipherType,
val viewState: ViewState,
val dialog: DialogState?,
val baseIconUrl: String,
val isIconLoadingDisabled: Boolean,
) : Parcelable {
/**
@@ -1487,7 +1548,7 @@ data class VaultItemState(
* @property canDelete Indicates if the cipher can be deleted.
* @property canAssignToCollections Indicates if the cipher can be assigned to
* collections.
* @property favorite Indicates that the cipher is favoried.
* @property favorite Indicates that the cipher is marked as a favorite item.
*/
@Parcelize
data class Common(
@@ -1504,6 +1565,8 @@ data class VaultItemState(
val canAssignToCollections: Boolean,
val canEdit: Boolean,
val favorite: Boolean,
val iconData: IconData,
val relatedLocations: ImmutableList<VaultItemLocation>,
) : Parcelable {
/**
@@ -2248,6 +2311,13 @@ sealed class VaultItemAction {
val isSaved: Boolean,
val file: File,
) : Internal()
/**
* Indicates the `isIconLoadingDisabled` setting has changed.
*/
data class IsIconLoadingDisabledUpdateReceive(
val isDisabled: Boolean,
) : Internal()
}
}

View File

@@ -0,0 +1,402 @@
package com.x8bit.bitwarden.ui.vault.feature.item.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Icon
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.VectorPainter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.cardStyle
import com.x8bit.bitwarden.ui.platform.base.util.nullableTestTag
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenExpandingHeader
import com.x8bit.bitwarden.ui.platform.components.icon.BitwardenIcon
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.item.model.VaultItemLocation
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* The max number of items that can be displayed before the "show more" text is visible.
*/
private const val EXPANDABLE_THRESHOLD = 2
/**
* Reusable composable for displaying the cipher name, favorite status, and related locations.
*/
@Suppress("LongMethod", "LongParameterList")
fun LazyListScope.itemHeader(
value: String,
isFavorite: Boolean,
relatedLocations: ImmutableList<VaultItemLocation>,
iconData: IconData,
isExpanded: Boolean,
iconTestTag: String? = null,
textFieldTestTag: String? = null,
onExpandClick: () -> Unit,
) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.defaultMinSize(minHeight = 60.dp)
.cardStyle(
cardStyle = CardStyle.Top(),
paddingVertical = 0.dp,
)
.padding(start = 16.dp),
) {
ItemHeaderIcon(
iconData = iconData,
testTag = iconTestTag,
modifier = Modifier.size(36.dp),
)
BitwardenTextField(
label = null,
value = value,
onValueChange = { },
readOnly = true,
singleLine = false,
actions = {
Icon(
painter = painterResource(
id = if (isFavorite) {
R.drawable.ic_favorite_full
} else {
R.drawable.ic_favorite_empty
},
),
contentDescription = stringResource(
id = if (isFavorite) R.string.favorite else R.string.unfavorite,
),
modifier = Modifier.padding(all = 12.dp),
)
},
textFieldTestTag = textFieldTestTag,
cardStyle = null,
textStyle = BitwardenTheme.typography.titleMedium,
)
}
}
if (relatedLocations.isEmpty()) {
item(key = "noFolder") {
ItemLocationListItem(
vectorPainter = rememberVectorPainter(R.drawable.ic_folder),
text = stringResource(R.string.no_folder),
iconTestTag = "NoFolderIcon",
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.animateItem()
.cardStyle(
cardStyle = CardStyle.Bottom,
paddingVertical = 0.dp,
paddingHorizontal = 16.dp,
),
)
}
return
}
itemsIndexed(
key = { index, _ -> "locations_$index" },
items = relatedLocations.take(EXPANDABLE_THRESHOLD),
) { index, location ->
ItemLocationListItem(
vectorPainter = rememberVectorPainter(location.icon),
iconTestTag = "ItemLocationIcon",
text = location.name,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem()
.cardStyle(
cardStyle = if (index == relatedLocations.size - 1) {
CardStyle.Bottom
} else {
CardStyle.Middle(hasDivider = false)
},
paddingVertical = 0.dp,
paddingHorizontal = 16.dp,
),
)
}
if (isExpanded) {
items(
key = { "expandableLocations_$it" },
items = relatedLocations.drop(EXPANDABLE_THRESHOLD),
) {
ItemLocationListItem(
vectorPainter = rememberVectorPainter(it.icon),
text = it.name,
iconTestTag = "ItemLocationIcon",
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem()
.cardStyle(
cardStyle = CardStyle.Middle(hasDivider = false),
paddingVertical = 0.dp,
paddingHorizontal = 16.dp,
),
)
}
}
if (relatedLocations.size > EXPANDABLE_THRESHOLD) {
item(key = "expandableLocationsShowMore") {
BitwardenExpandingHeader(
collapsedText = stringResource(R.string.show_more),
expandedText = stringResource(R.string.show_less),
isExpanded = isExpanded,
onClick = onExpandClick,
showExpansionIndicator = false,
shape = RectangleShape,
insets = PaddingValues(),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem()
.cardStyle(
cardStyle = CardStyle.Bottom,
paddingVertical = 0.dp,
),
)
}
}
}
@Composable
private fun ItemHeaderIcon(
iconData: IconData,
modifier: Modifier = Modifier,
testTag: String? = null,
) {
val isLocalIcon = iconData is IconData.Local
Box(
contentAlignment = Alignment.Center,
modifier = modifier.then(
if (isLocalIcon) {
Modifier.background(
color = BitwardenTheme.colorScheme.illustration.backgroundPrimary,
shape = BitwardenTheme.shapes.favicon,
)
} else {
Modifier
},
),
) {
BitwardenIcon(
iconData = iconData,
contentDescription = null,
tint = BitwardenTheme.colorScheme.illustration.outline,
modifier = Modifier
.nullableTestTag(testTag)
.then(
if (!isLocalIcon) Modifier.fillMaxSize() else Modifier,
),
)
}
}
@Composable
private fun LazyItemScope.ItemLocationListItem(
vectorPainter: VectorPainter,
iconTestTag: String?,
text: String,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = modifier
.padding(8.dp),
) {
Icon(
painter = vectorPainter,
tint = BitwardenTheme.colorScheme.icon.primary,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.nullableTestTag(iconTestTag),
)
Text(
text = text,
style = BitwardenTheme.typography.bodyLarge,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier
.padding(start = 16.dp)
.testTag("ItemLocationText"),
)
}
}
//region Previews
@Composable
@Preview
private fun ItemHeader_LocalIcon_Preview() {
var isExpanded by remember { mutableStateOf(false) }
BitwardenTheme {
LazyColumn {
itemHeader(
value = "Login without favicon",
isFavorite = true,
iconData = IconData.Local(
iconRes = R.drawable.ic_globe,
),
relatedLocations = persistentListOf(),
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
)
}
}
}
@Composable
@Preview
private fun ItemHeader_NetworkIcon_Preview() {
var isExpanded by remember { mutableStateOf(false) }
BitwardenTheme {
LazyColumn {
itemHeader(
value = "Login with favicon",
isFavorite = true,
iconData = IconData.Network(
uri = "mockuri",
fallbackIconRes = R.drawable.ic_globe,
),
relatedLocations = persistentListOf(),
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
)
}
}
}
@Composable
@Preview
private fun ItemHeader_Organization_Preview() {
var isExpanded by remember { mutableStateOf(false) }
BitwardenTheme {
LazyColumn {
itemHeader(
value = "Login without favicon",
isFavorite = true,
iconData = IconData.Local(
iconRes = R.drawable.ic_globe,
),
relatedLocations = persistentListOf(
VaultItemLocation.Organization("Stark Industries"),
),
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
)
}
}
}
@Composable
@Preview
private fun ItemNameField_Org_SingleCollection_Preview() {
var isExpanded by remember { mutableStateOf(false) }
BitwardenTheme {
LazyColumn {
itemHeader(
value = "Login without favicon",
isFavorite = true,
iconData = IconData.Local(
iconRes = R.drawable.ic_globe,
),
relatedLocations = persistentListOf(
VaultItemLocation.Organization("Stark Industries"),
VaultItemLocation.Collection("Marketing"),
),
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
)
}
}
}
@Composable
@Preview
private fun ItemNameField_Org_MultiCollection_Preview() {
var isExpanded by remember { mutableStateOf(false) }
BitwardenTheme {
LazyColumn {
itemHeader(
value = "Login without favicon",
isFavorite = true,
iconData = IconData.Local(
iconRes = R.drawable.ic_globe,
),
relatedLocations = persistentListOf(
VaultItemLocation.Organization("Stark Industries"),
VaultItemLocation.Collection("Marketing"),
VaultItemLocation.Collection("Product"),
),
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
)
}
}
}
@Composable
@Preview
private fun ItemNameField_Org_SingleCollection_Folder_Preview() {
var isExpanded by remember { mutableStateOf(false) }
BitwardenTheme {
LazyColumn {
itemHeader(
value = "Note without favicon",
isFavorite = true,
iconData = IconData.Local(
iconRes = R.drawable.ic_note,
),
relatedLocations = persistentListOf(
VaultItemLocation.Organization("Stark Industries"),
VaultItemLocation.Collection("Marketing"),
VaultItemLocation.Folder("Competition"),
),
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
)
}
}
}
//endregion Previews

View File

@@ -1,49 +0,0 @@
package com.x8bit.bitwarden.ui.vault.feature.item.component
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
/**
* Reusable composable for displaying the cipher name and favorite status.
*/
@Composable
fun ItemNameField(
value: String,
isFavorite: Boolean,
textFieldTestTag: String,
modifier: Modifier = Modifier,
) {
BitwardenTextField(
label = stringResource(id = R.string.item_name_required),
value = value,
onValueChange = { },
readOnly = true,
singleLine = false,
actions = {
Icon(
painter = painterResource(
id = if (isFavorite) {
R.drawable.ic_favorite_full
} else {
R.drawable.ic_favorite_empty
},
),
contentDescription = stringResource(
id = if (isFavorite) R.string.favorite else R.string.unfavorite,
),
modifier = Modifier.padding(all = 12.dp),
)
},
textFieldTestTag = textFieldTestTag,
cardStyle = CardStyle.Full,
modifier = modifier,
)
}

View File

@@ -0,0 +1,59 @@
package com.x8bit.bitwarden.ui.vault.feature.item.model
import android.os.Parcelable
import androidx.annotation.DrawableRes
import com.x8bit.bitwarden.R
import kotlinx.parcelize.Parcelize
/**
* Represents the location of a vault item.
*
* A vault item can be located in an [Organization], [Collection], or a [Folder].
* Each location type provides specific details and an associated icon.
*/
sealed class VaultItemLocation : Parcelable {
/**
* The name of the location. This can be the organization name, collection name, or folder name
* depending on the location type.
*/
abstract val name: String
/**
* Icon for the location
*/
@get:DrawableRes
abstract val icon: Int
/**
* Represents an organization assignment.
*/
@Parcelize
data class Organization(
override val name: String,
) : VaultItemLocation() {
override val icon: Int
get() = R.drawable.ic_organization
}
/**
* Represents a collection assignment.
*/
@Parcelize
data class Collection(
override val name: String,
) : VaultItemLocation() {
override val icon: Int
get() = R.drawable.ic_collections
}
/**
* Represents a folder assignment.
*/
@Parcelize
data class Folder(
override val name: String,
) : VaultItemLocation() {
override val icon: Int
get() = R.drawable.ic_folder
}
}

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.item.model
import com.bitwarden.vault.CipherView
import kotlinx.collections.immutable.ImmutableList
/**
* The state containing totp code item information and the cipher for the item.
@@ -10,6 +11,7 @@ import com.bitwarden.vault.CipherView
* @property canDelete Whether the item can be deleted.
* @property canAssociateToCollections Whether the item can be associated to a collection.
* @property canEdit Whether the item can be edited.
* @property relatedLocations The locations the item is assigned to.
*/
data class VaultItemStateData(
val cipher: CipherView?,
@@ -17,4 +19,5 @@ data class VaultItemStateData(
val canDelete: Boolean,
val canAssociateToCollections: Boolean,
val canEdit: Boolean,
val relatedLocations: ImmutableList<VaultItemLocation>,
)

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.item.util
import androidx.annotation.DrawableRes
import com.bitwarden.vault.CardView
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherType
@@ -17,12 +18,16 @@ 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.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.feature.item.model.VaultItemLocation
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
import kotlinx.collections.immutable.ImmutableList
import java.time.Clock
private const val LAST_UPDATED_DATE_TIME_PATTERN: String = "M/d/yy hh:mm a"
@@ -42,6 +47,9 @@ fun CipherView.toViewState(
canDelete: Boolean,
canAssignToCollections: Boolean,
canEdit: Boolean,
baseIconUrl: String,
isIconLoadingDisabled: Boolean,
relatedLocations: ImmutableList<VaultItemLocation>,
): VaultItemState.ViewState =
VaultItemState.ViewState.Content(
common = VaultItemState.ViewState.Content.Common(
@@ -86,6 +94,11 @@ fun CipherView.toViewState(
canAssignToCollections = canAssignToCollections,
canEdit = canEdit,
favorite = this.favorite,
iconData = this.toIconData(
baseIconUrl = baseIconUrl,
isIconLoadingDisabled = isIconLoadingDisabled,
),
relatedLocations = relatedLocations,
),
type = when (type) {
CipherType.LOGIN -> {
@@ -226,6 +239,35 @@ private fun Fido2Credential?.getCreationDateText(clock: Clock): Text? =
)
}
private fun CipherView.toIconData(
baseIconUrl: String,
isIconLoadingDisabled: Boolean,
): IconData {
return when (this.type) {
CipherType.LOGIN -> {
login?.uris.toLoginIconData(
baseIconUrl = baseIconUrl,
isIconLoadingDisabled = isIconLoadingDisabled,
usePasskeyDefaultIcon = false,
)
}
else -> {
IconData.Local(iconRes = this.type.iconRes)
}
}
}
@get:DrawableRes
private val CipherType.iconRes: Int
get() = when (this) {
CipherType.SECURE_NOTE -> R.drawable.ic_note
CipherType.CARD -> R.drawable.ic_payment_card
CipherType.IDENTITY -> R.drawable.ic_id_card
CipherType.SSH_KEY -> R.drawable.ic_ssh_key
CipherType.LOGIN -> R.drawable.ic_globe
}
private val IdentityView.identityAddress: String?
get() = listOfNotNull(
address1,

View File

@@ -0,0 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="25dp"
android:viewportWidth="24"
android:viewportHeight="25">
<path
android:pathData="M8.197,6.271C7.783,6.271 7.447,6.606 7.447,7.021C7.447,7.435 7.783,7.771 8.197,7.771H10.135C10.549,7.771 10.885,7.435 10.885,7.021C10.885,6.606 10.549,6.271 10.135,6.271H8.197Z"
android:fillColor="#000000"/>
<path
android:pathData="M13.365,6.271C12.951,6.271 12.615,6.606 12.615,7.021C12.615,7.435 12.951,7.771 13.365,7.771H15.303C15.717,7.771 16.053,7.435 16.053,7.021C16.053,6.606 15.717,6.271 15.303,6.271H13.365Z"
android:fillColor="#000000"/>
<path
android:pathData="M13.365,10.02C12.951,10.02 12.615,10.356 12.615,10.77C12.615,11.185 12.951,11.52 13.365,11.52H15.303C15.717,11.52 16.053,11.185 16.053,10.77C16.053,10.356 15.717,10.02 15.303,10.02H13.365Z"
android:fillColor="#000000"/>
<path
android:pathData="M12.615,14.52C12.615,14.106 12.951,13.77 13.365,13.77H15.303C15.717,13.77 16.053,14.106 16.053,14.52C16.053,14.934 15.717,15.27 15.303,15.27H13.365C12.951,15.27 12.615,14.934 12.615,14.52Z"
android:fillColor="#000000"/>
<path
android:pathData="M7.447,10.77C7.447,10.356 7.783,10.02 8.197,10.02H10.135C10.549,10.02 10.885,10.356 10.885,10.77C10.885,11.185 10.549,11.52 10.135,11.52H8.197C7.783,11.52 7.447,11.185 7.447,10.77Z"
android:fillColor="#000000"/>
<path
android:pathData="M8.197,13.77C7.783,13.77 7.447,14.106 7.447,14.52C7.447,14.934 7.783,15.27 8.197,15.27H10.135C10.549,15.27 10.885,14.934 10.885,14.52C10.885,14.106 10.549,13.77 10.135,13.77H8.197Z"
android:fillColor="#000000"/>
<path
android:pathData="M5.3,2.333C4.582,2.333 4,2.915 4,3.633V21.033C4,21.751 4.582,22.333 5.3,22.333H18.2C18.918,22.333 19.5,21.751 19.5,21.033V3.633C19.5,2.915 18.918,2.333 18.2,2.333H5.3ZM5.5,3.833V20.833H9.166V18.633C9.166,17.915 9.748,17.333 10.466,17.333H13.033C13.751,17.333 14.333,17.915 14.333,18.633V20.833H18V3.833H5.5Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

View File

@@ -1223,4 +1223,7 @@ Do you want to switch to this account?</string>
<string name="use_chrome_autofill_integration">Use Chrome autofill integration</string>
<string name="use_chrome_beta_autofill_integration">Use Chrome autofill integration (Beta)</string>
<string name="improves_login_filling_for_supported_websites_on_chrome">Improves login filling for supported websites on Chrome. Once enabled, youll be directed to Chrome settings to enable third-party autofill.</string>
<string name="show_more">Show more</string>
<string name="no_folder">No folder</string>
<string name="show_less">Show less</string>
</resources>

View File

@@ -45,6 +45,7 @@ fun createMockCipherView(
cipherType: CipherType = CipherType.LOGIN,
repromptType: CipherRepromptType = CipherRepromptType.NONE,
totp: String? = "mockTotp-$number",
organizationId: String? = "mockOrganizationId-$number",
folderId: String? = "mockId-$number",
clock: Clock = FIXED_CLOCK,
fido2Credentials: List<Fido2Credential>? = null,
@@ -52,7 +53,7 @@ fun createMockCipherView(
): CipherView =
CipherView(
id = "mockId-$number",
organizationId = "mockOrganizationId-$number",
organizationId = organizationId,
folderId = folderId,
collectionIds = listOf("mockId-$number"),
key = "mockKey-$number",

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
@@ -23,6 +24,7 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onSiblings
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.x8bit.bitwarden.R
@@ -30,6 +32,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.assertNoPopupExists
@@ -40,6 +43,7 @@ import com.x8bit.bitwarden.ui.util.onNodeWithContentDescriptionAfterScroll
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.feature.item.model.VaultItemLocation
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
@@ -49,6 +53,7 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertEquals
@@ -271,16 +276,16 @@ class VaultItemScreenTest : BaseComposeTest() {
mutableStateFlow.update { it.copy(viewState = typeState) }
composeTestRule
.onNodeWithTextAfterScroll("Item name (required)")
.assertTextContains("cipher")
.onNodeWithText("cipher")
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
updateCommonContent(currentState) { copy(name = "Test Name") }
}
composeTestRule
.onNodeWithTextAfterScroll("Item name (required)")
.assertTextContains("Test Name")
.onNodeWithText("Test Name")
.assertIsDisplayed()
}
}
@@ -313,6 +318,168 @@ class VaultItemScreenTest : BaseComposeTest() {
.assertIsDisplayed()
}
@Test
fun `no folder should be displayed according to state`() {
DEFAULT_VIEW_STATES.forEach { defaultViewState ->
mutableStateFlow.update {
DEFAULT_STATE.copy(
viewState = defaultViewState.copy(
common = DEFAULT_COMMON.copy(
relatedLocations = persistentListOf(),
),
),
)
}
composeTestRule
.onNodeWithText("No folder")
.assertIsDisplayed()
// Verify "No folder" is not displayed when relatedLocations is not empty
mutableStateFlow.update {
DEFAULT_STATE.copy(
viewState = defaultViewState.copy(
common = DEFAULT_COMMON.copy(
relatedLocations = persistentListOf(
VaultItemLocation.Collection("collection"),
),
),
),
)
}
composeTestRule
.onNodeWithText("No folder")
.assertDoesNotExist()
}
}
@Test
fun `organization locations should be displayed according to state`() {
val organizationName = "My organization"
DEFAULT_VIEW_STATES.forEach { viewState ->
mutableStateFlow.update {
DEFAULT_STATE.copy(
viewState = viewState.copy(
common = DEFAULT_COMMON.copy(
relatedLocations = persistentListOf(
VaultItemLocation.Organization(
organizationName,
),
),
),
),
)
}
composeTestRule
.onNodeWithText(organizationName)
.assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(
viewState = viewState.copy(
common = DEFAULT_COMMON.copy(
relatedLocations = persistentListOf(),
),
),
)
}
composeTestRule
.onNodeWithText(organizationName)
.assertDoesNotExist()
}
}
@Test
fun `collection locations should be displayed according to state`() {
DEFAULT_VIEW_STATES.forEach { viewState ->
mutableStateFlow.update {
DEFAULT_STATE.copy(
viewState = viewState.copy(
common = DEFAULT_COMMON.copy(
relatedLocations = persistentListOf(
VaultItemLocation.Collection("My collection"),
),
),
),
)
}
composeTestRule
.onNodeWithText("My collection")
.assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(
viewState = viewState.copy(
common = DEFAULT_COMMON.copy(
relatedLocations = persistentListOf(),
),
),
)
}
composeTestRule
.onNodeWithText("My collection")
.assertDoesNotExist()
}
}
@Test
fun `ExpandingHeader should be displayed according to state`() {
DEFAULT_VIEW_STATES.forEach { viewState ->
mutableStateFlow.update {
DEFAULT_STATE.copy(
viewState = viewState.copy(
common = DEFAULT_COMMON.copy(
relatedLocations = persistentListOf(
VaultItemLocation.Organization("My organization"),
VaultItemLocation.Collection("My collection"),
VaultItemLocation.Folder("My folder"),
),
),
),
)
}
composeTestRule
.onNodeWithTextAfterScroll("Show more")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Show less")
.assertIsDisplayed()
}
}
@Test
fun `ExpandingHeader should show expanded content according to state`() {
DEFAULT_VIEW_STATES.forEach { viewState ->
mutableStateFlow.update {
DEFAULT_STATE.copy(
viewState = viewState.copy(
common = DEFAULT_COMMON.copy(
relatedLocations = persistentListOf(
VaultItemLocation.Organization("My organization"),
VaultItemLocation.Collection("My collection"),
VaultItemLocation.Folder("My folder"),
),
),
),
)
}
// Verify only two locations are shown when content is collapsed.
composeTestRule
.onNodeWithText("My folder")
.assertIsNotDisplayed()
// Verify all locations are show when content is expanded.
composeTestRule
.onNodeWithText("Show more")
.performClick()
composeTestRule
.onNodeWithText("My folder")
.assertIsDisplayed()
}
}
@Test
fun `lastUpdated should be displayed according to state`() {
EMPTY_VIEW_STATES
@@ -1405,7 +1572,7 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule
.onNodeWithTag("CipherNotesCopyButton")
.performClick()
.performSemanticsAction(SemanticsActions.OnClick)
verify {
viewModel.trySendAction(VaultItemAction.Common.CopyNotesClick)
@@ -1569,7 +1736,11 @@ class VaultItemScreenTest : BaseComposeTest() {
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordVisibilityClicked(true))
viewModel.trySendAction(
VaultItemAction.ItemType.Login.PasswordVisibilityClicked(
true,
),
)
}
}
@@ -1735,7 +1906,7 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule
.onNodeWithContentDescriptionAfterScroll("Copy TOTP")
.performClick()
.performSemanticsAction(SemanticsActions.OnClick)
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyTotpClick)
@@ -2142,7 +2313,7 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule
.onNodeWithTag("IdentityCopyNameButton")
.performClick()
.performSemanticsAction(SemanticsActions.OnClick)
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyIdentityNameClick)
@@ -2204,7 +2375,7 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule
.onNodeWithTag("IdentityCopyPassportNumberButton")
.performClick()
.performSemanticsAction(SemanticsActions.OnClick)
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyPassportNumberClick)
@@ -2215,11 +2386,10 @@ class VaultItemScreenTest : BaseComposeTest() {
@Test
fun `in identity state, on copy license number field click should send CopyLicenseNumberClick`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) }
// We scroll to email, which is right after the license number to avoid clicking on the FAB
composeTestRule.onNodeWithTextAfterScroll("Email")
composeTestRule.onNodeWithTextAfterScroll("License number")
composeTestRule
.onNodeWithTag("IdentityCopyLicenseNumberButton")
.performClick()
.performSemanticsAction(SemanticsActions.OnClick)
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyLicenseNumberClick)
@@ -2248,7 +2418,7 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule
.onNodeWithTag("IdentityCopyPhoneButton")
.performClick()
.performSemanticsAction(SemanticsActions.OnClick)
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyPhoneClick)
@@ -2564,7 +2734,7 @@ class VaultItemScreenTest : BaseComposeTest() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_SSH_KEY_VIEW_STATE) }
composeTestRule
.onNodeWithContentDescriptionAfterScroll("Copy public key")
.performClick()
.performSemanticsAction(SemanticsActions.OnClick)
verify(exactly = 1) {
viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPublicKeyClick)
@@ -2583,7 +2753,7 @@ class VaultItemScreenTest : BaseComposeTest() {
)
}
composeTestRule
.onNodeWithText(privateKey)
.onNodeWithTextAfterScroll(privateKey)
.assertIsDisplayed()
}
@@ -2738,6 +2908,8 @@ private val DEFAULT_STATE: VaultItemState = VaultItemState(
cipherType = VaultItemCipherType.LOGIN,
viewState = VaultItemState.ViewState.Loading,
dialog = null,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
)
private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
@@ -2778,6 +2950,8 @@ private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
canAssignToCollections = true,
canEdit = true,
favorite = false,
iconData = IconData.Local(iconRes = R.drawable.ic_globe),
relatedLocations = persistentListOf(),
)
private val DEFAULT_PASSKEY = R.string.created_xy.asText(
@@ -2863,6 +3037,8 @@ private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common =
canAssignToCollections = true,
canEdit = true,
favorite = false,
iconData = IconData.Local(iconRes = R.drawable.ic_globe),
relatedLocations = persistentListOf(),
)
private val EMPTY_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login =
@@ -2923,55 +3099,55 @@ private val EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content =
private val EMPTY_IDENTITY_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = EMPTY_COMMON,
common = EMPTY_COMMON.copy(iconData = IconData.Local(R.drawable.ic_id_card)),
type = EMPTY_IDENTITY_TYPE,
)
private val EMPTY_CARD_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = EMPTY_COMMON,
common = EMPTY_COMMON.copy(iconData = IconData.Local(R.drawable.ic_payment_card)),
type = EMPTY_CARD_TYPE,
)
private val EMPTY_SECURE_NOTE_VIEW_STATE =
VaultItemState.ViewState.Content(
common = EMPTY_COMMON,
common = EMPTY_COMMON.copy(iconData = IconData.Local(R.drawable.ic_note)),
type = VaultItemState.ViewState.Content.ItemType.SecureNote,
)
private val EMPTY_SSH_KEY_VIEW_STATE =
VaultItemState.ViewState.Content(
common = EMPTY_COMMON,
common = EMPTY_COMMON.copy(iconData = IconData.Local(R.drawable.ic_ssh_key)),
type = EMPTY_SSH_KEY_TYPE,
)
private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
type = DEFAULT_LOGIN,
common = DEFAULT_COMMON,
type = DEFAULT_LOGIN,
)
private val DEFAULT_IDENTITY_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = DEFAULT_COMMON.copy(iconData = IconData.Local(R.drawable.ic_id_card)),
type = DEFAULT_IDENTITY,
common = DEFAULT_COMMON,
)
private val DEFAULT_CARD_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = DEFAULT_COMMON.copy(iconData = IconData.Local(R.drawable.ic_payment_card)),
type = DEFAULT_CARD,
common = DEFAULT_COMMON,
)
private val DEFAULT_SECURE_NOTE_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = DEFAULT_COMMON,
common = DEFAULT_COMMON.copy(iconData = IconData.Local(R.drawable.ic_note)),
type = VaultItemState.ViewState.Content.ItemType.SecureNote,
)
private val DEFAULT_SSH_KEY_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = DEFAULT_COMMON,
common = DEFAULT_COMMON.copy(iconData = IconData.Local(R.drawable.ic_ssh_key)),
type = DEFAULT_SSH_KEY,
)

View File

@@ -1,9 +1,19 @@
package com.x8bit.bitwarden.ui.vault.feature.item.util
import android.net.Uri
import com.bitwarden.vault.CipherType
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
@@ -16,6 +26,16 @@ class CipherViewExtensionsTest {
ZoneOffset.UTC,
)
@BeforeEach
fun setUp() {
setupMockUri()
}
@AfterEach
fun tearDown() {
unmockkStatic(Uri::class)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should transform full CipherView into ViewState Login Content maintaining re-prompt and visibility state`() {
@@ -48,6 +68,9 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
@@ -86,6 +109,9 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
@@ -117,6 +143,9 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
@@ -148,6 +177,9 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
@@ -185,6 +217,9 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
@@ -212,6 +247,9 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
@@ -237,11 +275,18 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false, isPremiumUser = true)
common = createCommonContent(
isEmpty = false,
isPremiumUser = true,
iconResId = R.drawable.ic_id_card,
)
.copy(currentCipher = cipherView),
type = createIdentityContent(isEmpty = false),
),
@@ -261,11 +306,18 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = true, isPremiumUser = true)
common = createCommonContent(
isEmpty = true,
isPremiumUser = true,
iconResId = R.drawable.ic_id_card,
)
.copy(currentCipher = cipherView),
type = createIdentityContent(isEmpty = true),
),
@@ -295,11 +347,18 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false, isPremiumUser = true)
common = createCommonContent(
isEmpty = false,
isPremiumUser = true,
iconResId = R.drawable.ic_id_card,
)
.copy(currentCipher = cipherView),
type = createIdentityContent(
isEmpty = false,
@@ -334,11 +393,18 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false, isPremiumUser = true).copy(
common = createCommonContent(
isEmpty = false,
isPremiumUser = true,
iconResId = R.drawable.ic_id_card,
).copy(
currentCipher = cipherView.copy(
identity = cipherView.identity?.copy(
address1 = null,
@@ -375,11 +441,18 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false, isPremiumUser = true)
common = createCommonContent(
isEmpty = false,
isPremiumUser = true,
iconResId = R.drawable.ic_note,
)
.copy(currentCipher = cipherView),
type = VaultItemState.ViewState.Content.ItemType.SecureNote,
),
@@ -400,10 +473,17 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
val expectedState = VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = true, isPremiumUser = true)
common = createCommonContent(
isEmpty = true,
isPremiumUser = true,
iconResId = R.drawable.ic_note,
)
.copy(currentCipher = cipherView),
type = VaultItemState.ViewState.Content.ItemType.SecureNote,
)
@@ -423,10 +503,17 @@ class CipherViewExtensionsTest {
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false, isPremiumUser = true).copy(
common = createCommonContent(
isEmpty = false,
isPremiumUser = true,
iconResId = R.drawable.ic_ssh_key,
).copy(
currentCipher = cipherView.copy(
name = "mockName",
sshKey = cipherView.sshKey?.copy(
@@ -441,4 +528,43 @@ class CipherViewExtensionsTest {
viewState,
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should transform full CipherView into ViewState with iconData based on cipher type`() {
mapOf<CipherType, Int>(
CipherType.LOGIN to R.drawable.ic_globe,
CipherType.IDENTITY to R.drawable.ic_id_card,
CipherType.CARD to R.drawable.ic_payment_card,
CipherType.SECURE_NOTE to R.drawable.ic_note,
CipherType.SSH_KEY to R.drawable.ic_ssh_key,
)
.forEach {
val cipherView = createCipherView(type = it.key, isEmpty = false)
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
it.value,
(viewState.asContentOrNull()?.common?.iconData as? IconData.Local)?.iconRes,
)
}
}
private fun setupMockUri() {
mockkStatic(Uri::class)
val uriMock = mockk<Uri>()
every { Uri.parse(any()) } returns uriMock
every { uriMock.host } returns "www.mockuri.com"
}
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.item.util
import androidx.annotation.DrawableRes
import com.bitwarden.vault.AttachmentView
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherType
@@ -14,9 +15,11 @@ import com.bitwarden.vault.SshKeyView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import kotlinx.collections.immutable.persistentListOf
import java.time.Instant
const val DEFAULT_IDENTITY_NAME: String = "Mr firstName middleName lastName"
@@ -160,6 +163,7 @@ fun createCipherView(type: CipherType, isEmpty: Boolean): CipherView =
fun createCommonContent(
isEmpty: Boolean,
isPremiumUser: Boolean,
@DrawableRes iconResId: Int = R.drawable.ic_globe,
): VaultItemState.ViewState.Content.Common =
if (isEmpty) {
VaultItemState.ViewState.Content.Common(
@@ -174,6 +178,8 @@ fun createCommonContent(
canAssignToCollections = true,
canEdit = true,
favorite = false,
relatedLocations = persistentListOf(),
iconData = IconData.Local(iconResId),
)
} else {
VaultItemState.ViewState.Content.Common(
@@ -221,6 +227,8 @@ fun createCommonContent(
canAssignToCollections = true,
canEdit = true,
favorite = false,
relatedLocations = persistentListOf(),
iconData = IconData.Local(iconResId),
)
}