diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt index 9704e2d61b..f30501b1ea 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt @@ -278,7 +278,7 @@ private val CipherListViewType.iconRes: Int CipherListViewType.SshKey -> BitwardenDrawable.ic_ssh_key CipherListViewType.BankAccount -> BitwardenDrawable.ic_payment_card CipherListViewType.DriversLicense -> BitwardenDrawable.ic_note - CipherListViewType.Passport -> BitwardenDrawable.ic_note + CipherListViewType.Passport -> BitwardenDrawable.ic_passport } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 71efdb6dea..223e83ab51 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -3137,19 +3137,19 @@ data class VaultAddEditState( */ @Parcelize data class Passport( - val surname: String = "", val givenName: String = "", + val surname: String = "", val dateOfBirth: String = "", - val birthPlace: String = "", val sex: String = "", + val birthPlace: String = "", val nationality: String = "", val passportNumber: String = "", val passportType: String = "", + val nationalIdentificationNumber: String = "", val issuingCountry: String = "", val issuingAuthority: String = "", val issueDate: String = "", val expirationDate: String = "", - val nationalIdentificationNumber: String = "", ) : ItemType() { override val itemTypeOption: ItemTypeOption get() = ItemTypeOption.PASSPORT diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt index e4cb252506..afd58ccbcf 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt @@ -127,12 +127,15 @@ fun CipherView.toViewState( } CipherType.PASSPORT -> VaultAddEditState.ViewState.Content.ItemType.Passport( - surname = passport?.surname.orEmpty(), givenName = passport?.givenName.orEmpty(), + surname = passport?.surname.orEmpty(), dateOfBirth = passport?.dateOfBirth.orEmpty(), + sex = passport?.sex.orEmpty(), + birthPlace = passport?.birthPlace.orEmpty(), nationality = passport?.nationality.orEmpty(), passportNumber = passport?.passportNumber.orEmpty(), passportType = passport?.passportType.orEmpty(), + nationalIdentificationNumber = passport?.nationalIdentificationNumber.orEmpty(), issuingCountry = passport?.issuingCountry.orEmpty(), issuingAuthority = passport?.issuingAuthority.orEmpty(), issueDate = passport?.issueDate.orEmpty(), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemPassportContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemPassportContent.kt new file mode 100644 index 0000000000..72b8cecc9f --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemPassportContent.kt @@ -0,0 +1,554 @@ +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.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 +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.ui.platform.base.util.standardHorizontalMargin +import com.bitwarden.ui.platform.base.util.toListItemCardStyle +import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton +import com.bitwarden.ui.platform.components.field.BitwardenPasswordField +import com.bitwarden.ui.platform.components.field.BitwardenTextField +import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText +import com.bitwarden.ui.platform.components.icon.model.IconData +import com.bitwarden.ui.platform.components.model.CardStyle +import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.ui.platform.resource.BitwardenDrawable +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.platform.theme.BitwardenTheme +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.ui.vault.feature.item.component.itemHeader +import com.x8bit.bitwarden.ui.vault.feature.item.component.vaultItemAttachments +import com.x8bit.bitwarden.ui.vault.feature.item.component.vaultItemCustomFields +import com.x8bit.bitwarden.ui.vault.feature.item.component.vaultItemHistory +import com.x8bit.bitwarden.ui.vault.feature.item.component.vaultItemNotes +import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers +import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultPassportItemTypeHandlers +import kotlinx.collections.immutable.persistentListOf + +/** + * The top level content UI state for the [VaultItemScreen] when viewing a passport + * cipher. Each populated field renders as a separate read-only row. The passport + * number and national identification number render with a reveal toggle and an + * inline copy affordance. + */ +@Suppress("LongMethod") +@Composable +fun VaultItemPassportContent( + commonState: VaultItemState.ViewState.Content.Common, + passportState: VaultItemState.ViewState.Content.ItemType.Passport, + vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers, + vaultPassportItemTypeHandlers: VaultPassportItemTypeHandlers, + modifier: 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, + isArchived = commonState.archived, + iconData = commonState.iconData, + relatedLocations = commonState.relatedLocations, + iconTestTag = "PassportItemNameIcon", + textFieldTestTag = "PassportItemNameEntry", + isExpanded = isExpanded, + onExpandClick = { isExpanded = !isExpanded }, + applyIconBackground = commonState.iconData is IconData.Local, + ) + item(key = "passportDetailsHeader") { + Spacer(modifier = Modifier.height(height = 16.dp)) + BitwardenListHeaderText( + label = stringResource(id = BitwardenString.passport_details), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .padding(horizontal = 16.dp) + .animateItem(), + ) + Spacer(modifier = Modifier.height(height = 8.dp)) + } + + passportState.givenName?.let { givenName -> + item(key = "givenName") { + PassportCopyField( + label = stringResource(id = BitwardenString.first_name), + value = givenName, + copyContentDescription = stringResource(id = BitwardenString.copy_first_name), + textFieldTestTag = "PassportItemGivenNameEntry", + copyActionTestTag = "PassportCopyGivenNameButton", + onCopyClick = vaultPassportItemTypeHandlers.onCopyGivenNameClick, + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState.propertyList.indexOf(element = givenName), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.surname?.let { surname -> + item(key = "surname") { + PassportCopyField( + label = stringResource(id = BitwardenString.last_name), + value = surname, + copyContentDescription = stringResource(id = BitwardenString.copy_last_name), + textFieldTestTag = "PassportItemSurnameEntry", + copyActionTestTag = "PassportCopySurnameButton", + onCopyClick = vaultPassportItemTypeHandlers.onCopySurnameClick, + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState.propertyList.indexOf(element = surname), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.dateOfBirth?.let { dateOfBirth -> + item(key = "dateOfBirth") { + BitwardenTextField( + label = stringResource(id = BitwardenString.date_of_birth), + value = dateOfBirth, + onValueChange = {}, + readOnly = true, + singleLine = false, + textFieldTestTag = "PassportItemDateOfBirthEntry", + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState.propertyList.indexOf(element = dateOfBirth), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.sex?.let { sex -> + item(key = "sex") { + BitwardenTextField( + label = stringResource(id = BitwardenString.sex), + value = sex, + onValueChange = {}, + readOnly = true, + singleLine = false, + textFieldTestTag = "PassportItemSexEntry", + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState.propertyList.indexOf(element = sex), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.birthPlace?.let { birthPlace -> + item(key = "birthPlace") { + BitwardenTextField( + label = stringResource(id = BitwardenString.birth_place), + value = birthPlace, + onValueChange = {}, + readOnly = true, + singleLine = false, + textFieldTestTag = "PassportItemBirthPlaceEntry", + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState.propertyList.indexOf(element = birthPlace), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.nationality?.let { nationality -> + item(key = "nationality") { + BitwardenTextField( + label = stringResource(id = BitwardenString.nationality), + value = nationality, + onValueChange = {}, + readOnly = true, + singleLine = false, + textFieldTestTag = "PassportItemNationalityEntry", + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState.propertyList.indexOf(element = nationality), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.passportNumber?.let { passportNumber -> + item(key = "passportNumber") { + BitwardenPasswordField( + label = stringResource(id = BitwardenString.passport_number), + value = passportNumber, + onValueChange = {}, + readOnly = true, + supportingContent = null, + actions = { + BitwardenStandardIconButton( + vectorIconRes = BitwardenDrawable.ic_copy, + contentDescription = stringResource( + id = BitwardenString.copy_passport_number, + ), + onClick = vaultPassportItemTypeHandlers.onCopyPassportNumberClick, + modifier = Modifier.testTag( + tag = "PassportCopyPassportNumberButton", + ), + ) + }, + passwordFieldTestTag = "PassportItemPassportNumberEntry", + showPasswordTestTag = "PassportViewPassportNumberButton", + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState + .propertyList + .indexOf(element = passportNumber), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.passportType?.let { passportType -> + item(key = "passportType") { + BitwardenTextField( + label = stringResource(id = BitwardenString.passport_type), + value = passportType, + onValueChange = {}, + readOnly = true, + singleLine = false, + textFieldTestTag = "PassportItemPassportTypeEntry", + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState.propertyList.indexOf(element = passportType), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.nationalIdentificationNumber?.let { nationalIdentificationNumber -> + item(key = "nationalIdentificationNumber") { + BitwardenPasswordField( + label = stringResource( + id = BitwardenString.national_identification_number, + ), + value = nationalIdentificationNumber, + onValueChange = {}, + readOnly = true, + supportingContent = null, + actions = { + BitwardenStandardIconButton( + vectorIconRes = BitwardenDrawable.ic_copy, + contentDescription = stringResource( + id = BitwardenString.copy_national_identification_number, + ), + onClick = vaultPassportItemTypeHandlers + .onCopyNationalIdentificationNumberClick, + modifier = Modifier.testTag( + tag = "PassportCopyNationalIdentificationNumberButton", + ), + ) + }, + passwordFieldTestTag = "PassportItemNationalIdentificationNumberEntry", + showPasswordTestTag = "PassportViewNationalIdentificationNumberButton", + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState + .propertyList + .indexOf(element = nationalIdentificationNumber), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.issuingCountry?.let { issuingCountry -> + item(key = "issuingCountry") { + BitwardenTextField( + label = stringResource(id = BitwardenString.issuing_country), + value = issuingCountry, + onValueChange = {}, + readOnly = true, + singleLine = false, + textFieldTestTag = "PassportItemIssuingCountryEntry", + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState.propertyList.indexOf(element = issuingCountry), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.issuingAuthority?.let { issuingAuthority -> + item(key = "issuingAuthority") { + BitwardenTextField( + label = stringResource(id = BitwardenString.issuing_authority), + value = issuingAuthority, + onValueChange = {}, + readOnly = true, + singleLine = false, + textFieldTestTag = "PassportItemIssuingAuthorityEntry", + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState + .propertyList + .indexOf(element = issuingAuthority), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.issueDate?.let { issueDate -> + item(key = "issueDate") { + BitwardenTextField( + label = stringResource(id = BitwardenString.issue_date), + value = issueDate, + onValueChange = {}, + readOnly = true, + singleLine = false, + textFieldTestTag = "PassportItemIssueDateEntry", + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState.propertyList.indexOf(element = issueDate), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + passportState.expirationDate?.let { expirationDate -> + item(key = "expirationDate") { + BitwardenTextField( + label = stringResource(id = BitwardenString.expiration_date), + value = expirationDate, + onValueChange = {}, + readOnly = true, + singleLine = false, + textFieldTestTag = "PassportItemExpirationDateEntry", + cardStyle = passportState + .propertyList + .toListItemCardStyle( + index = passportState.propertyList.indexOf(element = expirationDate), + dividerPadding = 0.dp, + ), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .animateItem(), + ) + } + } + + vaultItemNotes( + notes = commonState.notes, + vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers, + ) + + vaultItemCustomFields( + customFields = commonState.customFields, + vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers, + ) + + vaultItemAttachments( + attachments = commonState.attachments, + vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers, + ) + + vaultItemHistory( + commonState = commonState, + vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers, + loginPasswordRevisionDate = null, + ) + + item { + Spacer(modifier = Modifier.height(88.dp)) + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} + +@Composable +private fun PassportCopyField( + label: String, + value: String, + copyContentDescription: String, + textFieldTestTag: String, + copyActionTestTag: String, + onCopyClick: () -> Unit, + cardStyle: CardStyle, + modifier: Modifier = Modifier, +) { + BitwardenTextField( + label = label, + value = value, + onValueChange = {}, + readOnly = true, + singleLine = false, + actions = { + BitwardenStandardIconButton( + vectorIconRes = BitwardenDrawable.ic_copy, + contentDescription = copyContentDescription, + onClick = onCopyClick, + modifier = Modifier.testTag(tag = copyActionTestTag), + ) + }, + textFieldTestTag = textFieldTestTag, + cardStyle = cardStyle, + modifier = modifier, + ) +} + +//region Previews +@Composable +@Preview(showBackground = true, heightDp = 1200) +private fun VaultItemPassportContent_Preview() { + BitwardenTheme { + BitwardenScaffold() { + VaultItemPassportContent( + commonState = PREVIEW_COMMON, + passportState = PREVIEW_PASSPORT, + vaultCommonItemTypeHandlers = PREVIEW_COMMON_HANDLERS, + vaultPassportItemTypeHandlers = PREVIEW_PASSPORT_HANDLERS, + ) + } + } +} + +private val PREVIEW_COMMON: VaultItemState.ViewState.Content.Common = + VaultItemState.ViewState.Content.Common( + name = "Passport", + created = "May 13, 2026, 12:00 PM".asText(), + lastUpdated = "May 13, 2026, 12:00 PM".asText(), + notes = "Recovery code: 12323234324", + customFields = persistentListOf(), + requiresCloneConfirmation = false, + currentCipher = null, + attachments = persistentListOf(), + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + favorite = false, + archived = false, + passwordHistoryCount = null, + iconData = IconData.Local(iconRes = BitwardenDrawable.ic_passport), + relatedLocations = persistentListOf(), + hasOrganizations = false, + ) + +private val PREVIEW_PASSPORT: VaultItemState.ViewState.Content.ItemType.Passport = + VaultItemState.ViewState.Content.ItemType.Passport( + givenName = "Mitchell Allen", + surname = "Johnson", + dateOfBirth = "August 10, 1990", + sex = "Male", + birthPlace = "Madison, Wisconsin", + nationality = "United States of America", + passportNumber = "P12345678", + passportType = "P", + nationalIdentificationNumber = "N-987-654-321", + issuingCountry = "United States of America", + issuingAuthority = "Department of State", + issueDate = "August 10, 2021", + expirationDate = "August 10, 2031", + ) + +private val PREVIEW_COMMON_HANDLERS: VaultCommonItemTypeHandlers = + VaultCommonItemTypeHandlers( + onRefreshClick = {}, + onCopyCustomHiddenField = {}, + onCopyCustomTextField = {}, + onShowHiddenFieldClick = { _, _ -> }, + onAttachmentDownloadClick = {}, + onAttachmentPreviewClick = {}, + onCopyNotesClick = {}, + onPasswordHistoryClick = {}, + onUpgradeToPremiumClick = {}, + ) + +private val PREVIEW_PASSPORT_HANDLERS: VaultPassportItemTypeHandlers = + VaultPassportItemTypeHandlers( + onCopyGivenNameClick = {}, + onCopySurnameClick = {}, + onCopyPassportNumberClick = {}, + onCopyNationalIdentificationNumberClick = {}, + ) +//endregion Previews diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index e014f13b9d..cc461bbb5c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -47,6 +47,8 @@ import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultBankAccountItemTy 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.VaultDriversLicenseItemTypeHandlers +import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultPassportItemTypeHandlers +import com.x8bit.bitwarden.ui.vault.feature.item.handlers.rememberVaultPassportItemTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultIdentityItemTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultLoginItemTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultSshKeyItemTypeHandlers @@ -292,6 +294,9 @@ fun VaultItemScreen( vaultDriversLicenseItemTypeHandlers = remember(viewModel) { VaultDriversLicenseItemTypeHandlers.create(viewModel = viewModel) }, + vaultPassportItemTypeHandlers = rememberVaultPassportItemTypeHandlers( + viewModel = viewModel, + ), ) } } @@ -378,6 +383,7 @@ private fun VaultItemContent( vaultIdentityItemTypeHandlers: VaultIdentityItemTypeHandlers, vaultBankAccountItemTypeHandlers: VaultBankAccountItemTypeHandlers, vaultDriversLicenseItemTypeHandlers: VaultDriversLicenseItemTypeHandlers, + vaultPassportItemTypeHandlers: VaultPassportItemTypeHandlers, modifier: Modifier = Modifier, ) { when (viewState) { @@ -462,12 +468,11 @@ private fun VaultItemContent( } is VaultItemState.ViewState.Content.ItemType.Passport -> { - // TODO(PM-32806): Render dedicated content for Passport once the UI ships - // in its Story slice. Until then this branch is gated behind the - // pm-32009-new-item-types feature flag and cannot be received. - VaultItemSecureNoteContent( + VaultItemPassportContent( commonState = viewState.common, + passportState = viewState.type, vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers, + vaultPassportItemTypeHandlers = vaultPassportItemTypeHandlers, modifier = modifier, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 47500bf81d..5f6d59af3a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -244,6 +244,7 @@ class VaultItemViewModel @Inject constructor( handleDriversLicenseTypeActions(action) } + is VaultItemAction.ItemType.Passport -> handlePassportTypeActions(action) is VaultItemAction.Common -> handleCommonActions(action) is VaultItemAction.Internal -> handleInternalAction(action) } @@ -1253,6 +1254,83 @@ class VaultItemViewModel @Inject constructor( //endregion Driver's License Type Handlers + //region Passport Type Handlers + + private fun handlePassportTypeActions(action: VaultItemAction.ItemType.Passport) { + when (action) { + VaultItemAction.ItemType.Passport.CopyGivenNameClick -> { + handleCopyPassportGivenNameClick() + } + + VaultItemAction.ItemType.Passport.CopySurnameClick -> { + handleCopyPassportSurnameClick() + } + + VaultItemAction.ItemType.Passport.CopyPassportNumberClick -> { + handleCopyPassportItemNumberClick() + } + + VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick -> { + handleCopyNationalIdentificationNumberClick() + } + } + } + + private fun handleCopyPassportGivenNameClick() { + onPassportContent { _, passport -> + passport.givenName + ?.takeIf { it.isNotBlank() } + ?.let { givenName -> + clipboardManager.setText( + text = givenName, + toastDescriptorOverride = BitwardenString.first_name.asText(), + ) + } + } + } + + private fun handleCopyPassportSurnameClick() { + onPassportContent { _, passport -> + passport.surname + ?.takeIf { it.isNotBlank() } + ?.let { surname -> + clipboardManager.setText( + text = surname, + toastDescriptorOverride = BitwardenString.last_name.asText(), + ) + } + } + } + + private fun handleCopyPassportItemNumberClick() { + onPassportContent { _, passport -> + passport.passportNumber + ?.takeIf { it.isNotBlank() } + ?.let { passportNumber -> + clipboardManager.setText( + text = passportNumber, + toastDescriptorOverride = BitwardenString.passport_number.asText(), + ) + } + } + } + + private fun handleCopyNationalIdentificationNumberClick() { + onPassportContent { _, passport -> + passport.nationalIdentificationNumber + ?.takeIf { it.isNotBlank() } + ?.let { nationalIdentificationNumber -> + clipboardManager.setText( + text = nationalIdentificationNumber, + toastDescriptorOverride = + BitwardenString.national_identification_number.asText(), + ) + } + } + } + + //endregion Passport Type Handlers + //region Internal Type Handlers private fun handleInternalAction(action: VaultItemAction.Internal) { @@ -1665,6 +1743,21 @@ class VaultItemViewModel @Inject constructor( } } } + + private inline fun onPassportContent( + crossinline block: ( + VaultItemState.ViewState.Content, + VaultItemState.ViewState.Content.ItemType.Passport, + ) -> Unit, + ) { + state.viewState.asContentOrNull() + ?.let { content -> + (content.type as? VaultItemState.ViewState.Content.ItemType.Passport) + ?.let { passportContent -> + block(content, passportContent) + } + } + } } /** @@ -2191,17 +2284,41 @@ data class VaultItemState( * Represents the `Passport` item type. */ data class Passport( - val surname: String?, val givenName: String?, + val surname: String?, val dateOfBirth: String?, + val sex: String?, + val birthPlace: String?, val nationality: String?, val passportNumber: String?, val passportType: String?, + val nationalIdentificationNumber: String?, val issuingCountry: String?, val issuingAuthority: String?, val issueDate: String?, val expirationDate: String?, - ) : ItemType() + ) : ItemType() { + + /** + * An ordered list of populated Passport elements. + */ + val propertyList: ImmutableList + get() = persistentListOfNotNull( + givenName, + surname, + dateOfBirth, + sex, + birthPlace, + nationality, + passportNumber, + passportType, + nationalIdentificationNumber, + issuingCountry, + issuingAuthority, + issueDate, + expirationDate, + ) + } } } @@ -2741,6 +2858,33 @@ sealed class VaultItemAction { */ data object CopyLicenseNumberClick : DriversLicense() } + + /** + * Represents actions specific to the Passport type. + */ + sealed class Passport : ItemType() { + + /** + * The user has clicked the copy button for the given name. + */ + data object CopyGivenNameClick : Passport() + + /** + * The user has clicked the copy button for the surname. + */ + data object CopySurnameClick : Passport() + + /** + * The user has clicked the copy button for the passport number. + */ + data object CopyPassportNumberClick : Passport() + + /** + * The user has clicked the copy button for the national identification + * number. + */ + data object CopyNationalIdentificationNumberClick : Passport() + } } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultPassportItemTypeHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultPassportItemTypeHandlers.kt new file mode 100644 index 0000000000..5ac67174ee --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultPassportItemTypeHandlers.kt @@ -0,0 +1,71 @@ +package com.x8bit.bitwarden.ui.vault.feature.item.handlers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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 + * passport items in a vault. + * + * @property onCopyGivenNameClick Handles the user clicking the copy button next to the + * given name. + * @property onCopySurnameClick Handles the user clicking the copy button next to the + * surname. + * @property onCopyPassportNumberClick Handles the user clicking the copy button next to the + * passport number. + * @property onCopyNationalIdentificationNumberClick Handles the user clicking the copy button + * next to the national identification number. + */ +data class VaultPassportItemTypeHandlers( + val onCopyGivenNameClick: () -> Unit, + val onCopySurnameClick: () -> Unit, + val onCopyPassportNumberClick: () -> Unit, + val onCopyNationalIdentificationNumberClick: () -> Unit, +) { + @Suppress("UndocumentedPublicClass") + companion object { + + /** + * Creates the [VaultPassportItemTypeHandlers] using the [viewModel] to send + * desired actions. + */ + fun create(viewModel: VaultItemViewModel): VaultPassportItemTypeHandlers = + VaultPassportItemTypeHandlers( + onCopyGivenNameClick = { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyGivenNameClick, + ) + }, + onCopySurnameClick = { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopySurnameClick, + ) + }, + onCopyPassportNumberClick = { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyPassportNumberClick, + ) + }, + onCopyNationalIdentificationNumberClick = { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport + .CopyNationalIdentificationNumberClick, + ) + }, + ) + } +} + +/** + * Helper function to remember a [VaultPassportItemTypeHandlers] instance in a [Composable] + * scope. + */ +@Composable +fun rememberVaultPassportItemTypeHandlers( + viewModel: VaultItemViewModel, +): VaultPassportItemTypeHandlers = + remember(viewModel) { + VaultPassportItemTypeHandlers.create(viewModel = viewModel) + } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt index d829c97cc7..e773ab5d27 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -247,16 +247,19 @@ fun CipherView.toViewState( } CipherType.PASSPORT -> VaultItemState.ViewState.Content.ItemType.Passport( - surname = passport?.surname.orEmpty(), - givenName = passport?.givenName.orEmpty(), - dateOfBirth = passport?.dateOfBirth.orEmpty(), - nationality = passport?.nationality.orEmpty(), - passportNumber = passport?.passportNumber.orEmpty(), - passportType = passport?.passportType.orEmpty(), - issuingCountry = passport?.issuingCountry.orEmpty(), - issuingAuthority = passport?.issuingAuthority.orEmpty(), - issueDate = passport?.issueDate.orEmpty(), - expirationDate = passport?.expirationDate.orEmpty(), + givenName = passport?.givenName, + surname = passport?.surname, + dateOfBirth = passport?.dateOfBirth, + sex = passport?.sex, + birthPlace = passport?.birthPlace, + nationality = passport?.nationality, + passportNumber = passport?.passportNumber, + passportType = passport?.passportType, + nationalIdentificationNumber = passport?.nationalIdentificationNumber, + issuingCountry = passport?.issuingCountry, + issuingAuthority = passport?.issuingAuthority, + issueDate = passport?.issueDate, + expirationDate = passport?.expirationDate, ) }, ) @@ -350,7 +353,7 @@ private val CipherType.iconRes: Int CipherType.LOGIN -> BitwardenDrawable.ic_globe CipherType.BANK_ACCOUNT -> BitwardenDrawable.ic_payment_card CipherType.DRIVERS_LICENSE -> BitwardenDrawable.ic_note - CipherType.PASSPORT -> BitwardenDrawable.ic_note + CipherType.PASSPORT -> BitwardenDrawable.ic_passport } @get:DrawableRes diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 53012f6d5b..97139f26ba 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -622,7 +622,7 @@ private val CipherListViewType.iconRes: Int CipherListViewType.SshKey -> BitwardenDrawable.ic_ssh_key CipherListViewType.BankAccount -> BitwardenDrawable.ic_payment_card CipherListViewType.DriversLicense -> BitwardenDrawable.ic_note - CipherListViewType.Passport -> BitwardenDrawable.ic_note + CipherListViewType.Passport -> BitwardenDrawable.ic_passport } private fun List.applyFilters( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt index d08cf294c4..8d8e4bd23e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt @@ -144,7 +144,7 @@ private fun VaultAddEditState.ViewState.Content.ItemType.toPassport(): PassportV surname = it.surname.orNullIfBlank(), givenName = it.givenName.orNullIfBlank(), dateOfBirth = it.dateOfBirth.orNullIfBlank(), - birthPlace = it.dateOfBirth.orNullIfBlank(), + birthPlace = it.birthPlace.orNullIfBlank(), sex = it.sex.orNullIfBlank(), nationality = it.nationality.orNullIfBlank(), passportNumber = it.passportNumber.orNullIfBlank(), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt index 55461b02d1..812bd6ea5a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt @@ -16,6 +16,7 @@ import com.bitwarden.vault.LoginView import com.bitwarden.vault.PasswordHistoryView import com.bitwarden.vault.SecureNoteType import com.bitwarden.vault.SecureNoteView +import com.bitwarden.vault.PassportView import com.bitwarden.vault.SshKeyView import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.model.UserState @@ -376,6 +377,58 @@ class CipherViewExtensionsTest { ) } + @Test + fun `toViewState should create a Passport ViewState`() { + val cipherView = DEFAULT_PASSPORT_CIPHER_VIEW + + val result = cipherView.toViewState( + isClone = false, + isPremium = false, + isIndividualVaultDisabled = false, + totpData = null, + resourceManager = resourceManager, + clock = FIXED_CLOCK, + canDelete = true, + canAssignToCollections = true, + ) + + assertEquals( + VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common( + originalCipher = cipherView, + name = "cipher", + favorite = false, + masterPasswordReprompt = true, + notes = "Lots of notes", + customFieldData = listOf( + VaultAddEditState.Custom.BooleanField(TEST_ID, "TestBoolean", false), + VaultAddEditState.Custom.TextField(TEST_ID, "TestText", "TestText"), + VaultAddEditState.Custom.HiddenField(TEST_ID, "TestHidden", "TestHidden"), + ), + availableFolders = emptyList(), + availableOwners = emptyList(), + ), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.Passport( + givenName = "the given name", + surname = "the surname", + dateOfBirth = "the date of birth", + sex = "the sex", + birthPlace = "the birth place", + nationality = "the nationality", + passportNumber = "the passport number", + passportType = "the passport type", + nationalIdentificationNumber = "the national identification number", + issuingCountry = "the issuing country", + issuingAuthority = "the issuing authority", + issueDate = "the issue date", + expirationDate = "the expiration date", + ), + ), + result, + ) + } + @Test fun `toViewState with isClone true should append clone text to the cipher name`() { val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW @@ -879,6 +932,45 @@ private val DEFAULT_SSH_KEY_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.c ), ) +private val DEFAULT_PASSPORT_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy( + type = CipherType.PASSPORT, + fields = listOf( + FieldView( + name = "TestBoolean", + value = false.toString(), + type = FieldType.BOOLEAN, + linkedId = null, + ), + FieldView( + name = "TestText", + value = "TestText", + type = FieldType.TEXT, + linkedId = null, + ), + FieldView( + name = "TestHidden", + value = "TestHidden", + type = FieldType.HIDDEN, + linkedId = null, + ), + ), + passport = PassportView( + surname = "the surname", + givenName = "the given name", + dateOfBirth = "the date of birth", + birthPlace = "the birth place", + sex = "the sex", + nationality = "the nationality", + passportNumber = "the passport number", + passportType = "the passport type", + issuingCountry = "the issuing country", + issuingAuthority = "the issuing authority", + issueDate = "the issue date", + expirationDate = "the expiration date", + nationalIdentificationNumber = "the national identification number", + ), +) + private const val TEST_ID = "testID" private val NO_FOLDER_ITEM = VaultAddEditState.Folder( id = null, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index a3aa1ace2d..673804b502 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -3750,6 +3750,262 @@ class VaultItemScreenTest : BitwardenComposeTest() { } //endregion drivers license + + //region passport + + @Test + fun `in passport state, all fields should be displayed when populated`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + + composeTestRule.onNodeWithTextAfterScroll("the given name").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("the surname").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("the date of birth").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("the sex").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("the birth place").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("the nationality").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("the passport type").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("the issuing country").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("the issuing authority").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("the issue date").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("the expiration date").assertIsDisplayed() + } + + @Test + fun `in passport state, on copy given name click should send CopyGivenNameClick`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule + .onNodeWithContentDescriptionAfterScroll("Copy first name") + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyGivenNameClick, + ) + } + } + + @Test + fun `in passport state, on copy surname click should send CopySurnameClick`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule + .onNode(hasScrollToNodeAction()) + .performScrollToNode(hasTestTag("PassportCopySurnameButton")) + composeTestRule + .onNodeWithTag("PassportCopySurnameButton") + .performSemanticsAction(SemanticsActions.OnClick) + + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopySurnameClick, + ) + } + } + + @Test + fun `in passport state, on copy passport number click should send CopyPassportNumberClick`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule + .onNodeWithContentDescriptionAfterScroll("Copy passport number") + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyPassportNumberClick, + ) + } + } + + @Test + fun `in passport state, on copy national id click should send the matching action`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule + .onNodeWithContentDescriptionAfterScroll("Copy national identification number") + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick, + ) + } + } + + @Test + fun `in passport state, givenName should be displayed according to state`() { + val givenName = "the given name" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(givenName).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(givenName = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(givenName) + } + + @Test + fun `in passport state, surname should be displayed according to state`() { + val surname = "the surname" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(surname).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(surname = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(surname) + } + + @Test + fun `in passport state, dateOfBirth should be displayed according to state`() { + val dateOfBirth = "the date of birth" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(dateOfBirth).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(dateOfBirth = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(dateOfBirth) + } + + @Test + fun `in passport state, sex should be displayed according to state`() { + val sex = "the sex" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(sex).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(sex = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(sex) + } + + @Test + fun `in passport state, birthPlace should be displayed according to state`() { + val birthPlace = "the birth place" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(birthPlace).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(birthPlace = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(birthPlace) + } + + @Test + fun `in passport state, nationality should be displayed according to state`() { + val nationality = "the nationality" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(nationality).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(nationality = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(nationality) + } + + @Test + fun `in passport state, passportNumber should be displayed according to state`() { + val passportNumberLabel = "Passport number" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(passportNumberLabel).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(passportNumber = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(passportNumberLabel) + } + + @Test + fun `in passport state, passportType should be displayed according to state`() { + val passportType = "the passport type" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(passportType).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(passportType = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(passportType) + } + + @Test + fun `in passport state, nationalIdentificationNumber should be displayed according to state`() { + val nationalIdLabel = "National identification number" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(nationalIdLabel).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(nationalIdentificationNumber = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(nationalIdLabel) + } + + @Test + fun `in passport state, issuingCountry should be displayed according to state`() { + val issuingCountry = "the issuing country" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(issuingCountry).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(issuingCountry = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(issuingCountry) + } + + @Test + fun `in passport state, issuingAuthority should be displayed according to state`() { + val issuingAuthority = "the issuing authority" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(issuingAuthority).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(issuingAuthority = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(issuingAuthority) + } + + @Test + fun `in passport state, issueDate should be displayed according to state`() { + val issueDate = "the issue date" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(issueDate).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(issueDate = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(issueDate) + } + + @Test + fun `in passport state, expirationDate should be displayed according to state`() { + val expirationDate = "the expiration date" + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(expirationDate).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updatePassportType(currentState) { copy(expirationDate = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(expirationDate) + } + + @Test + fun `in passport state, edit fab should be displayed`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) } + + composeTestRule.onNodeWithContentDescription("Edit item").assertIsDisplayed() + } + + //endregion passport } //region Helper functions @@ -3869,6 +4125,29 @@ private fun updateDriversLicenseType( return currentState.copy(viewState = updatedType) } +private fun updatePassportType( + currentState: VaultItemState, + transform: VaultItemState.ViewState.Content.ItemType.Passport.() -> + VaultItemState.ViewState.Content.ItemType.Passport, +): VaultItemState { + val updatedType = when (val viewState = currentState.viewState) { + is VaultItemState.ViewState.Content -> { + when (val type = viewState.type) { + is VaultItemState.ViewState.Content.ItemType.Passport -> { + viewState.copy( + type = type.transform(), + ) + } + + else -> viewState + } + } + + else -> viewState + } + return currentState.copy(viewState = updatedType) +} + private fun updateCommonContent( currentState: VaultItemState, transform: VaultItemState.ViewState.Content.Common.() @@ -4184,6 +4463,29 @@ private val DEFAULT_DRIVERS_LICENSE_VIEW_STATE: VaultItemState.ViewState.Content type = DEFAULT_DRIVERS_LICENSE, ) +private val DEFAULT_PASSPORT: VaultItemState.ViewState.Content.ItemType.Passport = + VaultItemState.ViewState.Content.ItemType.Passport( + givenName = "the given name", + surname = "the surname", + dateOfBirth = "the date of birth", + sex = "the sex", + birthPlace = "the birth place", + nationality = "the nationality", + passportNumber = "the passport number", + passportType = "the passport type", + nationalIdentificationNumber = "the national identification number", + issuingCountry = "the issuing country", + issuingAuthority = "the issuing authority", + issueDate = "the issue date", + expirationDate = "the expiration date", + ) + +private val DEFAULT_PASSPORT_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = DEFAULT_COMMON.copy(iconData = IconData.Local(BitwardenDrawable.ic_globe)), + type = DEFAULT_PASSPORT, + ) + private val EMPTY_VIEW_STATES = listOf( EMPTY_LOGIN_VIEW_STATE, EMPTY_IDENTITY_VIEW_STATE, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 7fad97be40..a5ea94d208 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -3131,6 +3131,275 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } + @Nested + inner class PassportActions { + private lateinit var viewModel: VaultItemViewModel + + @BeforeEach + fun setup() { + viewModel = createViewModel( + state = DEFAULT_STATE.copy(viewState = PASSPORT_VIEW_STATE), + ) + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + hasOrganizations = true, + ) + } returns PASSPORT_VIEW_STATE + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + } + + @Test + fun `on CopyGivenNameClick should copy given name to clipboard`() = runTest { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyGivenNameClick, + ) + verify(exactly = 1) { + clipboardManager.setText( + text = "Missy", + toastDescriptorOverride = BitwardenString.first_name.asText(), + ) + } + } + + @Test + fun `on CopyGivenNameClick with null given name should not copy to clipboard`() = runTest { + val emptyState = PASSPORT_VIEW_STATE.copy( + type = DEFAULT_PASSPORT_TYPE.copy(givenName = null), + ) + viewModel = createViewModelWithPassportState(emptyState) + + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyGivenNameClick, + ) + + verify(exactly = 0) { + clipboardManager.setText( + text = any(), + toastDescriptorOverride = any(), + ) + } + } + + @Test + fun `on CopyGivenNameClick with blank given name should not copy to clipboard`() = runTest { + val emptyState = PASSPORT_VIEW_STATE.copy( + type = DEFAULT_PASSPORT_TYPE.copy(givenName = " "), + ) + viewModel = createViewModelWithPassportState(emptyState) + + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyGivenNameClick, + ) + + verify(exactly = 0) { + clipboardManager.setText( + text = any(), + toastDescriptorOverride = any(), + ) + } + } + + @Test + fun `on CopySurnameClick should copy surname to clipboard`() = runTest { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopySurnameClick, + ) + verify(exactly = 1) { + clipboardManager.setText( + text = "Katner", + toastDescriptorOverride = BitwardenString.last_name.asText(), + ) + } + } + + @Test + fun `on CopySurnameClick with null surname should not copy to clipboard`() = runTest { + val emptyState = PASSPORT_VIEW_STATE.copy( + type = DEFAULT_PASSPORT_TYPE.copy(surname = null), + ) + viewModel = createViewModelWithPassportState(emptyState) + + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopySurnameClick, + ) + + verify(exactly = 0) { + clipboardManager.setText( + text = any(), + toastDescriptorOverride = any(), + ) + } + } + + @Test + fun `on CopySurnameClick with blank surname should not copy to clipboard`() = runTest { + val emptyState = PASSPORT_VIEW_STATE.copy( + type = DEFAULT_PASSPORT_TYPE.copy(surname = " "), + ) + viewModel = createViewModelWithPassportState(emptyState) + + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopySurnameClick, + ) + + verify(exactly = 0) { + clipboardManager.setText( + text = any(), + toastDescriptorOverride = any(), + ) + } + } + + @Test + fun `on CopyPassportNumberClick should copy passport number to clipboard`() = runTest { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyPassportNumberClick, + ) + verify(exactly = 1) { + clipboardManager.setText( + text = "P12345678", + toastDescriptorOverride = BitwardenString.passport_number.asText(), + ) + } + } + + @Test + fun `on CopyPassportNumberClick with null passport number should not copy to clipboard`() = + runTest { + val emptyState = PASSPORT_VIEW_STATE.copy( + type = DEFAULT_PASSPORT_TYPE.copy(passportNumber = null), + ) + viewModel = createViewModelWithPassportState(emptyState) + + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyPassportNumberClick, + ) + + verify(exactly = 0) { + clipboardManager.setText( + text = any(), + toastDescriptorOverride = any(), + ) + } + } + + @Test + fun `on CopyPassportNumberClick with blank passport number should not copy to clipboard`() = + runTest { + val emptyState = PASSPORT_VIEW_STATE.copy( + type = DEFAULT_PASSPORT_TYPE.copy(passportNumber = " "), + ) + viewModel = createViewModelWithPassportState(emptyState) + + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyPassportNumberClick, + ) + + verify(exactly = 0) { + clipboardManager.setText( + text = any(), + toastDescriptorOverride = any(), + ) + } + } + + @Test + fun `on CopyNationalIdentificationNumberClick should copy the value to clipboard`() = + runTest { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick, + ) + verify(exactly = 1) { + clipboardManager.setText( + text = "N-987-654-321", + toastDescriptorOverride = + BitwardenString.national_identification_number.asText(), + ) + } + } + + @Test + fun `on CopyNationalIdentificationNumberClick with null value should not copy`() = + runTest { + val emptyState = PASSPORT_VIEW_STATE.copy( + type = DEFAULT_PASSPORT_TYPE.copy(nationalIdentificationNumber = null), + ) + viewModel = createViewModelWithPassportState(emptyState) + + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick, + ) + + verify(exactly = 0) { + clipboardManager.setText( + text = any(), + toastDescriptorOverride = any(), + ) + } + } + + @Test + fun `on CopyNationalIdentificationNumberClick with blank value should not copy`() = + runTest { + val emptyState = PASSPORT_VIEW_STATE.copy( + type = DEFAULT_PASSPORT_TYPE.copy(nationalIdentificationNumber = " "), + ) + viewModel = createViewModelWithPassportState(emptyState) + + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick, + ) + + verify(exactly = 0) { + clipboardManager.setText( + text = any(), + toastDescriptorOverride = any(), + ) + } + } + + private fun createViewModelWithPassportState( + viewState: VaultItemState.ViewState.Content, + ): VaultItemViewModel { + every { + mockCipherView.toViewState( + previousState = any(), + isPremiumUser = true, + totpCodeItemData = null, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + hasOrganizations = true, + ) + } returns viewState + val newViewModel = createViewModel( + state = DEFAULT_STATE.copy(viewState = viewState), + ) + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + return newViewModel + } + } + @Nested inner class VaultItemFlow { @BeforeEach @@ -3792,5 +4061,29 @@ class VaultItemViewModelTest : BaseViewModelTest() { common = DEFAULT_COMMON, type = DEFAULT_DRIVERS_LICENSE_TYPE, ) + + private val DEFAULT_PASSPORT_TYPE: + VaultItemState.ViewState.Content.ItemType.Passport = + VaultItemState.ViewState.Content.ItemType.Passport( + givenName = "Missy", + surname = "Katner", + dateOfBirth = "August 10, 1990", + sex = "Female", + birthPlace = "Madison, WI", + nationality = "USA", + passportNumber = "P12345678", + passportType = "Regular", + nationalIdentificationNumber = "N-987-654-321", + issuingCountry = "USA", + issuingAuthority = "Department of State", + issueDate = "August 10, 2021", + expirationDate = "August 10, 2031", + ) + + private val PASSPORT_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = DEFAULT_COMMON, + type = DEFAULT_PASSPORT_TYPE, + ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultPassportItemTypeHandlersTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultPassportItemTypeHandlersTest.kt new file mode 100644 index 0000000000..04509e8a5e --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultPassportItemTypeHandlersTest.kt @@ -0,0 +1,56 @@ +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 +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class VaultPassportItemTypeHandlersTest { + + private val viewModel = mockk { + every { trySendAction(any()) } returns Unit + } + private val handlers = VaultPassportItemTypeHandlers.create(viewModel = viewModel) + + @Test + fun `onCopyGivenNameClick should send CopyGivenNameClick action`() { + handlers.onCopyGivenNameClick() + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyGivenNameClick, + ) + } + } + + @Test + fun `onCopySurnameClick should send CopySurnameClick action`() { + handlers.onCopySurnameClick() + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopySurnameClick, + ) + } + } + + @Test + fun `onCopyPassportNumberClick should send CopyPassportNumberClick action`() { + handlers.onCopyPassportNumberClick() + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyPassportNumberClick, + ) + } + } + + @Test + fun `onCopyNationalIdentificationNumberClick should send the matching action`() { + handlers.onCopyNationalIdentificationNumberClick() + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick, + ) + } + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt index 5c6b02f680..f79a2f952f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt @@ -6,6 +6,7 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.vault.CipherType import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCardView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockDriversLicenseView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPassportView 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.VaultCardBrand @@ -22,6 +23,7 @@ import java.time.Clock import java.time.Instant import java.time.ZoneOffset +@Suppress("LargeClass") class CipherViewExtensionsTest { private val fixedClock: Clock = Clock.fixed( @@ -541,6 +543,99 @@ class CipherViewExtensionsTest { ) } + @Test + fun `toViewState should transform full CipherView into ViewState Passport Content`() { + val cipherView = createCipherView(type = CipherType.PASSPORT, isEmpty = false) + .copy(passport = createMockPassportView(number = 1)) + val viewState = cipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + clock = fixedClock, + canDelete = true, + canRestore = true, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = "https://example.com/", + isIconLoadingDisabled = true, + relatedLocations = persistentListOf(), + hasOrganizations = true, + ) + + assertEquals( + VaultItemState.ViewState.Content( + common = createCommonContent( + isEmpty = false, + isPremiumUser = true, + iconResId = BitwardenDrawable.ic_passport, + ) + .copy(currentCipher = cipherView), + type = VaultItemState.ViewState.Content.ItemType.Passport( + givenName = "mockGivenName-1", + surname = "mockSurname-1", + dateOfBirth = "mockDateOfBirth-1", + sex = "mockSex-1", + birthPlace = "mockBirthPlace-1", + nationality = "mockNationality-1", + passportNumber = "mockPassportNumber-1", + passportType = "mockPassportType-1", + nationalIdentificationNumber = "mockNationalIdentificationNumber-1", + issuingCountry = "mockIssuingCountry-1", + issuingAuthority = "mockIssuingAuthority-1", + issueDate = "mockIssueDate-1", + expirationDate = "mockExpirationDate-1", + ), + ), + viewState, + ) + } + + @Test + fun `toViewState should transform empty CipherView into ViewState Passport Content`() { + val cipherView = createCipherView(type = CipherType.PASSPORT, isEmpty = true) + val viewState = cipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + clock = fixedClock, + canDelete = true, + canRestore = true, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = "https://example.com/", + isIconLoadingDisabled = true, + relatedLocations = persistentListOf(), + hasOrganizations = true, + ) + + assertEquals( + VaultItemState.ViewState.Content( + common = createCommonContent( + isEmpty = true, + isPremiumUser = true, + iconResId = BitwardenDrawable.ic_passport, + ) + .copy(currentCipher = cipherView), + type = VaultItemState.ViewState.Content.ItemType.Passport( + givenName = null, + surname = null, + dateOfBirth = null, + sex = null, + birthPlace = null, + nationality = null, + passportNumber = null, + passportType = null, + nationalIdentificationNumber = null, + issuingCountry = null, + issuingAuthority = null, + issueDate = null, + expirationDate = null, + ), + ), + viewState, + ) + } + @Suppress("MaxLineLength") @Test fun `toViewState should transform full CipherView into ViewState with iconData based on cipher type`() { diff --git a/ui/src/main/res/drawable/ic_passport.xml b/ui/src/main/res/drawable/ic_passport.xml new file mode 100644 index 0000000000..a1f07295e3 --- /dev/null +++ b/ui/src/main/res/drawable/ic_passport.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 5eecc5f571..1cd453788a 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1360,4 +1360,9 @@ Do you want to switch to this account? Licenses There are no licenses in your vault. Your request was interrupted because the app needed to re-authenticate. Please try again. + Passport details + Sex + Birth place + National identification number + Copy national identification number