diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconResource.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconResource.kt index 9f1bbd94fd..29680ce69a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconResource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconResource.kt @@ -1,6 +1,10 @@ package com.x8bit.bitwarden.ui.platform.components.model +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import com.x8bit.bitwarden.ui.platform.base.util.Text /** * Data class representing the resources required for an icon. @@ -12,3 +16,31 @@ data class IconResource( val iconPainter: Painter, val contentDescription: String, ) + +/** + * Data class representing the resources required for an icon and is friendly to use in ViewModels. + * + * @property iconRes Resource for the icon. + * @property contentDescription The icon's content description. + */ +data class IconRes( + @DrawableRes + val iconRes: Int, + val contentDescription: Text, +) + +/** + * A helper method to convert a list of [IconRes] to a list of [IconResource]. + */ +@Composable +fun List.toIconResources(): List = this.map { it.toIconResource() } + +/** + * A helper method to convert an [IconRes] to an [IconResource]. + */ +@Composable +fun IconRes.toIconResource(): IconResource = + IconResource( + iconPainter = painterResource(id = iconRes), + contentDescription = contentDescription(), + ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt index c411d72f00..e4104ae65a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderTextWithSupportLabel import com.x8bit.bitwarden.ui.platform.components.BitwardenListItem import com.x8bit.bitwarden.ui.platform.components.SelectionItemData +import com.x8bit.bitwarden.ui.platform.components.model.toIconResources import kotlinx.collections.immutable.toPersistentList /** @@ -42,6 +43,10 @@ fun VaultItemListingContent( label = it.title, supportingLabel = it.subtitle, onClick = { vaultItemClick(it.id) }, + trailingLabelIcons = it + .extraIconList + .toIconResources() + .toPersistentList(), selectionDataList = it .overflowOptions .map { option -> diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index a7f265a6dd..65490993f6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -17,6 +17,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.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.components.model.IconRes import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.determineListingPredicate import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toItemListingType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toViewState @@ -28,6 +29,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.parcelize.Parcelize +import java.time.Clock import javax.inject.Inject /** @@ -38,6 +40,7 @@ import javax.inject.Inject @Suppress("MagicNumber", "TooManyFunctions") class VaultItemListingViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val clock: Clock, private val clipboardManager: BitwardenClipboardManager, private val vaultRepository: VaultRepository, private val environmentRepository: EnvironmentRepository, @@ -271,7 +274,10 @@ class VaultItemListingViewModel @Inject constructor( .filter { sendView -> sendView.determineListingPredicate(listingType) } - .toViewState(baseWebSendUrl = state.baseWebSendUrl) + .toViewState( + baseWebSendUrl = state.baseWebSendUrl, + clock = clock, + ) } }, dialogState = currentState.dialogState.takeUnless { clearDialogState }, @@ -356,6 +362,7 @@ data class VaultItemListingState( val title: String, val subtitle: String?, val iconData: IconData, + val extraIconList: List, val overflowOptions: List, ) { /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 97fb2e3f95..a539902ba4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -10,10 +10,13 @@ import com.bitwarden.core.SendView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.components.model.IconRes +import com.x8bit.bitwarden.ui.tools.feature.send.model.SendStatusIcon import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingsAction import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData +import java.time.Clock /** * Determines a predicate to filter a list of [CipherView] based on the @@ -92,10 +95,14 @@ fun List.toViewState( */ fun List.toViewState( baseWebSendUrl: String, + clock: Clock, ): VaultItemListingState.ViewState = if (isNotEmpty()) { VaultItemListingState.ViewState.Content( - displayItemList = toDisplayItemList(baseWebSendUrl = baseWebSendUrl), + displayItemList = toDisplayItemList( + baseWebSendUrl = baseWebSendUrl, + clock = clock, + ), ) } else { VaultItemListingState.ViewState.NoItems @@ -143,8 +150,14 @@ private fun List.toDisplayItemList( private fun List.toDisplayItemList( baseWebSendUrl: String, + clock: Clock, ): List = - this.map { it.toDisplayItem(baseWebSendUrl = baseWebSendUrl) } + this.map { + it.toDisplayItem( + baseWebSendUrl = baseWebSendUrl, + clock = clock, + ) + } private fun CipherView.toDisplayItem( baseIconUrl: String, @@ -158,6 +171,7 @@ private fun CipherView.toDisplayItem( baseIconUrl = baseIconUrl, isIconLoadingDisabled = isIconLoadingDisabled, ), + extraIconList = emptyList(), overflowOptions = emptyList(), ) @@ -181,6 +195,7 @@ private fun CipherView.toIconData( private fun SendView.toDisplayItem( baseWebSendUrl: String, + clock: Clock, ): VaultItemListingState.DisplayItem = VaultItemListingState.DisplayItem( id = id.orEmpty(), @@ -192,6 +207,23 @@ private fun SendView.toDisplayItem( SendType.FILE -> R.drawable.ic_send_file }, ), + extraIconList = listOfNotNull( + SendStatusIcon.DISABLED + .takeIf { disabled } + ?.let { IconRes(iconRes = it.iconRes, contentDescription = it.contentDescription) }, + SendStatusIcon.PASSWORD + .takeIf { hasPassword } + ?.let { IconRes(iconRes = it.iconRes, contentDescription = it.contentDescription) }, + SendStatusIcon.MAX_ACCESS_COUNT_REACHED + .takeIf { maxAccessCount?.let { maxCount -> accessCount >= maxCount } == true } + ?.let { IconRes(iconRes = it.iconRes, contentDescription = it.contentDescription) }, + SendStatusIcon.EXPIRED + .takeIf { expirationDate?.isBefore(clock.instant()) == true } + ?.let { IconRes(iconRes = it.iconRes, contentDescription = it.contentDescription) }, + SendStatusIcon.PENDING_DELETE + .takeIf { deletionDate.isBefore(clock.instant()) } + ?.let { IconRes(iconRes = it.iconRes, contentDescription = it.contentDescription) }, + ), overflowOptions = listOfNotNull( VaultItemListingState.DisplayItem.OverflowItem( title = R.string.edit.asText(), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 8c5e82f90e..db3e9e4956 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -25,6 +25,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl 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.components.model.IconRes import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.util.assertNoDialogExists import com.x8bit.bitwarden.ui.util.isProgressBar @@ -641,6 +642,28 @@ private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem = title = "mockTitle-$number", subtitle = "mockSubtitle-$number", iconData = IconData.Local(R.drawable.ic_card_item), + extraIconList = listOf( + IconRes( + iconRes = R.drawable.ic_send_disabled, + contentDescription = R.string.disabled.asText(), + ), + IconRes( + iconRes = R.drawable.ic_send_password, + contentDescription = R.string.password.asText(), + ), + IconRes( + iconRes = R.drawable.ic_send_max_access_count_reached, + contentDescription = R.string.maximum_access_count_reached.asText(), + ), + IconRes( + iconRes = R.drawable.ic_send_expired, + contentDescription = R.string.expired.asText(), + ), + IconRes( + iconRes = R.drawable.ic_send_pending_delete, + contentDescription = R.string.pending_delete.asText(), + ), + ), overflowOptions = listOf( VaultItemListingState.DisplayItem.OverflowItem( title = R.string.edit.asText(), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 4f0eb96e30..57875b4293 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -36,9 +36,17 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset class VaultItemListingViewModelTest : BaseViewModelTest() { + private val clock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + private val clipboardManager: BitwardenClipboardManager = mockk() private val mutableVaultDataStateFlow = @@ -636,6 +644,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ): VaultItemListingViewModel = VaultItemListingViewModel( savedStateHandle = savedStateHandle, + clock = clock, clipboardManager = clipboardManager, vaultRepository = vaultRepository, environmentRepository = environmentRepository, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index 94933f330d..2ee0dd0950 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -17,9 +17,17 @@ import io.mockk.mockkStatic import io.mockk.unmockkStatic import org.junit.Assert.assertEquals import org.junit.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset class VaultItemListingDataExtensionsTest { + private val clock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + @Test @Suppress("MaxLineLength") fun `determineListingPredicate should return the correct predicate for non trash Login cipherView`() { @@ -361,6 +369,7 @@ class VaultItemListingDataExtensionsTest { val result = sendViewList.toViewState( baseWebSendUrl = Environment.Us.environmentUrlData.baseWebSendUrl, + clock = clock, ) assertEquals( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt index dc5dc9464b..ad1093349a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt @@ -5,6 +5,7 @@ import com.bitwarden.core.SendType import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.components.model.IconRes import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingsAction @@ -25,6 +26,7 @@ fun createMockDisplayItemForCipher( "https://vault.bitwarden.com/icons/www.mockuri.com/icon.png", fallbackIconRes = R.drawable.ic_login_item, ), + extraIconList = emptyList(), overflowOptions = emptyList(), ) } @@ -35,6 +37,7 @@ fun createMockDisplayItemForCipher( title = "mockName-$number", subtitle = null, iconData = IconData.Local(R.drawable.ic_secure_note_item), + extraIconList = emptyList(), overflowOptions = emptyList(), ) } @@ -45,6 +48,7 @@ fun createMockDisplayItemForCipher( title = "mockName-$number", subtitle = "er-$number", iconData = IconData.Local(R.drawable.ic_card_item), + extraIconList = emptyList(), overflowOptions = emptyList(), ) } @@ -55,6 +59,7 @@ fun createMockDisplayItemForCipher( title = "mockName-$number", subtitle = "mockFirstName-${number}mockLastName-$number", iconData = IconData.Local(R.drawable.ic_identity_item), + extraIconList = emptyList(), overflowOptions = emptyList(), ) } @@ -75,6 +80,16 @@ fun createMockDisplayItemForSend( title = "mockName-$number", subtitle = "2023-10-27T12:00:00Z", iconData = IconData.Local(R.drawable.ic_send_file), + extraIconList = listOf( + IconRes( + iconRes = R.drawable.ic_send_password, + contentDescription = R.string.password.asText(), + ), + IconRes( + iconRes = R.drawable.ic_send_max_access_count_reached, + contentDescription = R.string.maximum_access_count_reached.asText(), + ), + ), overflowOptions = listOfNotNull( VaultItemListingState.DisplayItem.OverflowItem( title = R.string.edit.asText(), @@ -112,6 +127,16 @@ fun createMockDisplayItemForSend( title = "mockName-$number", subtitle = "2023-10-27T12:00:00Z", iconData = IconData.Local(R.drawable.ic_send_text), + extraIconList = listOf( + IconRes( + iconRes = R.drawable.ic_send_password, + contentDescription = R.string.password.asText(), + ), + IconRes( + iconRes = R.drawable.ic_send_max_access_count_reached, + contentDescription = R.string.maximum_access_count_reached.asText(), + ), + ), overflowOptions = listOfNotNull( VaultItemListingState.DisplayItem.OverflowItem( title = R.string.edit.asText(),