BIT-513: View Card Item (#573)

This commit is contained in:
Ramsey Smith
2024-01-11 16:16:55 -07:00
committed by Álison Fernandes
parent d16e0c6573
commit 7a6088a23d
14 changed files with 819 additions and 24 deletions

View File

@@ -12,6 +12,7 @@ import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth
import com.x8bit.bitwarden.ui.vault.model.VaultIdentityTitle
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType.Companion.fromId
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
import java.util.UUID
/**
@@ -36,6 +37,7 @@ fun CipherView.toViewState(): VaultAddEditState.ViewState =
number = card?.number.orEmpty(),
brand = card?.brand.toBrandOrDefault(),
expirationMonth = card?.expMonth.toExpirationMonthOrDefault(),
expirationYear = card?.expYear.orEmpty(),
securityCode = card?.code.orEmpty(),
)
CipherType.IDENTITY -> VaultAddEditState.ViewState.Content.ItemType.Identity(
@@ -109,13 +111,12 @@ private fun String?.toTitleOrDefault(): VaultIdentityTitle =
?: VaultIdentityTitle.SELECT
private fun String?.toBrandOrDefault(): VaultCardBrand =
VaultCardBrand
.entries
.find { it.name == this }
this
?.findVaultCardBrandWithNameOrNull()
?: VaultCardBrand.SELECT
private fun String?.toExpirationMonthOrDefault(): VaultCardExpirationMonth =
VaultCardExpirationMonth
.entries
.find { it.name == this }
.find { it.number == this }
?: VaultCardExpirationMonth.SELECT

View File

@@ -0,0 +1,220 @@
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.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.BitwardenIconButtonWithResource
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.model.IconResource
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
/**
* The top level content UI state for the [VaultItemScreen] when viewing a Card cipher.
*/
@Suppress("LongMethod")
@Composable
fun VaultItemCardContent(
commonState: VaultItemState.ViewState.Content.Common,
cardState: VaultItemState.ViewState.Content.ItemType.Card,
vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers,
vaultCardItemTypeHandlers: VaultCardItemTypeHandlers,
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),
)
}
cardState.cardholderName?.let { cardholderName ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.cardholder_name),
value = cardholderName,
onValueChange = {},
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
cardState.number?.let { number ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenPasswordFieldWithActions(
label = stringResource(id = R.string.number),
value = number,
onValueChange = {},
readOnly = true,
singleLine = false,
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy_number),
),
onClick = vaultCardItemTypeHandlers.onCopyNumberClick,
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
if (cardState.brand != null && cardState.brand != VaultCardBrand.SELECT) {
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.brand),
value = cardState.brand.value(),
onValueChange = {},
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
cardState.expiration?.let { expiration ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.expiration),
value = expiration,
onValueChange = {},
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
cardState.securityCode?.let { securityCode ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenPasswordFieldWithActions(
label = stringResource(id = R.string.security_code),
value = securityCode,
onValueChange = {},
readOnly = true,
singleLine = false,
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(
id = R.string.copy_security_code,
),
),
onClick = vaultCardItemTypeHandlers.onCopySecurityCodeClick,
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
commonState.notes?.let { notes ->
item {
Spacer(modifier = Modifier.height(4.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.notes),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.notes),
value = notes,
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())
}
}
}

View File

@@ -39,6 +39,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
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.feature.item.handlers.VaultLoginItemTypeHandlers
@@ -137,6 +138,9 @@ fun VaultItemScreen(
vaultLoginItemTypeHandlers = remember(viewModel) {
VaultLoginItemTypeHandlers.create(viewModel = viewModel)
},
vaultCardItemTypeHandlers = remember(viewModel) {
VaultCardItemTypeHandlers.create(viewModel = viewModel)
},
)
}
}
@@ -176,6 +180,7 @@ private fun VaultItemContent(
viewState: VaultItemState.ViewState,
vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers,
vaultLoginItemTypeHandlers: VaultLoginItemTypeHandlers,
vaultCardItemTypeHandlers: VaultCardItemTypeHandlers,
modifier: Modifier = Modifier,
) {
when (viewState) {
@@ -198,7 +203,13 @@ private fun VaultItemContent(
}
is VaultItemState.ViewState.Content.ItemType.Card -> {
// TODO UI for viewing Card BIT-513
VaultItemCardContent(
commonState = viewState.common,
cardState = viewState.type,
vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers,
vaultCardItemTypeHandlers = vaultCardItemTypeHandlers,
modifier = modifier,
)
}
is VaultItemState.ViewState.Content.ItemType.Identity -> {

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import android.os.Parcelable
import android.util.Log
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.CipherView
@@ -17,6 +18,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
@@ -65,8 +67,10 @@ class VaultItemViewModel @Inject constructor(
}
override fun handleAction(action: VaultItemAction) {
Log.d("ramsey", "handleAction: action $action")
when (action) {
is VaultItemAction.ItemType.Login -> handleLoginTypeActions(action)
is VaultItemAction.ItemType.Card -> handleCardTypeActions(action)
is VaultItemAction.Common -> handleCommonActions(action)
is VaultItemAction.Internal -> handleInternalAction(action)
}
@@ -311,6 +315,43 @@ class VaultItemViewModel @Inject constructor(
//endregion Login Type Handlers
//region Card Type Handlers
private fun handleCardTypeActions(action: VaultItemAction.ItemType.Card) {
when (action) {
VaultItemAction.ItemType.Card.CopyNumberClick -> handleCopyNumberClick()
VaultItemAction.ItemType.Card.CopySecurityCodeClick -> handleCopySecurityCodeClick()
}
}
private fun handleCopyNumberClick() {
onCardContent { content, card ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog)
}
return@onCardContent
}
val number = requireNotNull(card.number)
clipboardManager.setText(text = number)
}
}
private fun handleCopySecurityCodeClick() {
onCardContent { content, card ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog)
}
return@onCardContent
}
val securityCode = requireNotNull(card.securityCode)
clipboardManager.setText(text = securityCode)
}
}
//endregion Card Type Handlers
//region Internal Type Handlers
private fun handleInternalAction(action: VaultItemAction.Internal) {
@@ -452,6 +493,21 @@ class VaultItemViewModel @Inject constructor(
}
}
}
private inline fun onCardContent(
crossinline block: (
VaultItemState.ViewState.Content,
VaultItemState.ViewState.Content.ItemType.Card,
) -> Unit,
) {
(state.viewState as? VaultItemState.ViewState.Content)
?.let { content ->
(content.type as? VaultItemState.ViewState.Content.ItemType.Card)
?.let { loginContent ->
block(content, loginContent)
}
}
}
}
/**
@@ -646,8 +702,20 @@ data class VaultItemState(
/**
* Represents the `Card` item type.
*
* @property cardholderName The cardholder name for the card.
* @property number The number for the card.
* @property brand The brand for the card.
* @property expiration The expiration for the card.
* @property securityCode The securityCode for the card.
*/
data object Card : ItemType()
data class Card(
val cardholderName: String?,
val number: String?,
val brand: VaultCardBrand?,
val expiration: String?,
val securityCode: String?,
) : ItemType()
}
}
}
@@ -828,6 +896,22 @@ sealed class VaultItemAction {
val isVisible: Boolean,
) : Login()
}
/**
* Represents actions specific to the Card type.
*/
sealed class Card : ItemType() {
/**
* The user has clicked the copy button for the number.
*/
data object CopyNumberClick : Card()
/**
* The user has clicked the copy button for the security code.
*/
data object CopySecurityCodeClick : Card()
}
}
/**

View File

@@ -0,0 +1,29 @@
package com.x8bit.bitwarden.ui.vault.feature.item.handlers
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemAction
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemViewModel
/**
* A collection of handler functions for managing actions within the context of viewing card
* items in a vault.
*/
data class VaultCardItemTypeHandlers(
val onCopyNumberClick: () -> Unit,
val onCopySecurityCodeClick: () -> Unit,
) {
companion object {
/**
* Creates the [VaultCardItemTypeHandlers] using the [viewModel] to send desired actions.
*/
fun create(viewModel: VaultItemViewModel): VaultCardItemTypeHandlers =
VaultCardItemTypeHandlers(
onCopyNumberClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick)
},
onCopySecurityCodeClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick)
},
)
}
}

View File

@@ -4,7 +4,7 @@ import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemAction
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemViewModel
/**
* A collection of handler functions for managing actions within the context of viewing identity
* A collection of handler functions for managing actions within the context of viewing login
* items in a vault.
*/
@Suppress("LongParameterList")

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.item.util
import com.bitwarden.core.CardView
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
@@ -14,7 +15,9 @@ 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
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 java.time.format.DateTimeFormatter
import java.util.TimeZone
@@ -64,7 +67,13 @@ fun CipherView.toViewState(
}
CipherType.CARD -> {
VaultItemState.ViewState.Content.ItemType.Card
VaultItemState.ViewState.Content.ItemType.Card(
cardholderName = card?.cardholderName,
number = card?.number,
brand = card?.cardBrand,
expiration = card?.expiration,
securityCode = card?.code,
)
}
CipherType.IDENTITY -> {
@@ -140,3 +149,16 @@ private val IdentityView.identityName: String?
)
.joinToString(" ")
.orNullIfBlank()
private val CardView.cardBrand: VaultCardBrand?
get() = brand
?.findVaultCardBrandWithNameOrNull()
.takeUnless { it == VaultCardBrand.SELECT }
private val CardView.expiration: String?
get() = listOfNotNull(
expMonth?.padStart(length = 2, padChar = '0'),
expYear,
)
.joinToString("/")
.orNullIfBlank()

View File

@@ -47,7 +47,7 @@ fun VaultAddEditState.ViewState.Content.toCipherView(): CipherView =
// Fields we always grab from the UI
name = common.name,
notes = common.notes,
notes = common.notes.orNullIfBlank(),
favorite = common.favorite,
// TODO Use real folder ID (BIT-528)
folderId = common.originalCipher?.folderId,
@@ -74,7 +74,7 @@ private fun VaultAddEditState.ViewState.Content.ItemType.toCardView(): CardView?
.takeUnless { month ->
month == VaultCardExpirationMonth.SELECT
}
?.name,
?.number,
expYear = it.expirationYear.orNullIfBlank(),
code = it.securityCode.orNullIfBlank(),
brand = it

View File

@@ -21,3 +21,19 @@ enum class VaultCardBrand(val value: Text) {
RUPAY(value = "RuPay".asText()),
OTHER(value = R.string.other.asText()),
}
/**
* Returns a [VaultCardBrand] with the provided [String] or null.
*/
fun String.findVaultCardBrandWithNameOrNull(): VaultCardBrand? =
VaultCardBrand
.entries
.find { vaultCardBrand ->
vaultCardBrand.name.lowercaseWithoutSpacesOrUnderScores ==
this.lowercaseWithoutSpacesOrUnderScores
}
private val String.lowercaseWithoutSpacesOrUnderScores: String
get() = lowercase()
.replace(" ", "")
.replace("_", "")

View File

@@ -12,20 +12,60 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.util.SELECT_TEXT
*/
enum class VaultCardExpirationMonth(
val value: Text,
val number: String,
) {
SELECT(value = SELECT_TEXT),
JANUARY(value = R.string.january.dateText("01 - ")),
FEBRUARY(value = R.string.february.dateText("02 - ")),
MARCH(value = R.string.march.dateText("03 - ")),
APRIL(value = R.string.april.dateText("04 - ")),
MAY(value = R.string.may.dateText("05 - ")),
JUNE(value = R.string.june.dateText("06 - ")),
JULY(value = R.string.july.dateText("07 - ")),
AUGUST(value = R.string.august.dateText("08 - ")),
SEPTEMBER(value = R.string.september.dateText("09 - ")),
OCTOBER(value = R.string.october.dateText("10 - ")),
NOVEMBER(value = R.string.november.dateText("11 - ")),
DECEMBER(value = R.string.december.dateText("12 - ")),
SELECT(
value = SELECT_TEXT,
number = "0",
),
JANUARY(
value = R.string.january.dateText("01 - "),
number = "1",
),
FEBRUARY(
value = R.string.february.dateText("02 - "),
number = "2",
),
MARCH(
value = R.string.march.dateText("03 - "),
number = "3",
),
APRIL(
value = R.string.april.dateText("04 - "),
number = "4",
),
MAY(
value = R.string.may.dateText("05 - "),
number = "5",
),
JUNE(
value = R.string.june.dateText("06 - "),
number = "6",
),
JULY(
value = R.string.july.dateText("07 - "),
number = "7",
),
AUGUST(
value = R.string.august.dateText("08 - "),
number = "8",
),
SEPTEMBER(
value = R.string.september.dateText("09 - "),
number = "9",
),
OCTOBER(
value = R.string.october.dateText("10 - "),
number = "10",
),
NOVEMBER(
value = R.string.november.dateText("11 - "),
number = "11",
),
DECEMBER(
value = R.string.december.dateText("12 - "),
number = "12",
),
}
private fun @receiver:StringRes Int.dateText(prefix: String): Text =