mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-603: Display Collections on Vault screen (#386)
This commit is contained in:
parent
727b0c0efc
commit
f18c43dd16
10 changed files with 216 additions and 6 deletions
|
@ -25,6 +25,7 @@ fun VaultContent(
|
|||
state: VaultState.ViewState.Content,
|
||||
vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit,
|
||||
folderClick: (VaultState.ViewState.FolderItem) -> Unit,
|
||||
collectionClick: (VaultState.ViewState.CollectionItem) -> Unit,
|
||||
loginGroupClick: () -> Unit,
|
||||
cardGroupClick: () -> Unit,
|
||||
identityGroupClick: () -> Unit,
|
||||
|
@ -208,6 +209,40 @@ fun VaultContent(
|
|||
}
|
||||
}
|
||||
|
||||
if (state.collectionItems.isNotEmpty()) {
|
||||
item {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
BitwardenListHeaderTextWithSupportLabel(
|
||||
label = stringResource(id = R.string.collections),
|
||||
supportingLabel = state.collectionItems.count().toString(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(state.collectionItems) { collection ->
|
||||
VaultGroupListItem(
|
||||
startIcon = painterResource(id = R.drawable.ic_collection),
|
||||
label = collection.name,
|
||||
supportingLabel = collection.itemCount.toString(),
|
||||
onClick = { collectionClick(collection) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
|
|
|
@ -105,6 +105,11 @@ fun VaultScreen(
|
|||
folderClick = remember(viewModel) {
|
||||
{ folderItem -> viewModel.trySendAction(VaultAction.FolderClick(folderItem)) }
|
||||
},
|
||||
collectionClick = remember(viewModel) {
|
||||
{ collectionItem ->
|
||||
viewModel.trySendAction(VaultAction.CollectionClick(collectionItem))
|
||||
}
|
||||
},
|
||||
loginGroupClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultAction.LoginGroupClick) }
|
||||
},
|
||||
|
@ -140,6 +145,7 @@ private fun VaultScreenScaffold(
|
|||
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
|
||||
vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit,
|
||||
folderClick: (VaultState.ViewState.FolderItem) -> Unit,
|
||||
collectionClick: (VaultState.ViewState.CollectionItem) -> Unit,
|
||||
loginGroupClick: () -> Unit,
|
||||
cardGroupClick: () -> Unit,
|
||||
identityGroupClick: () -> Unit,
|
||||
|
@ -209,6 +215,7 @@ private fun VaultScreenScaffold(
|
|||
state = viewState,
|
||||
vaultItemClick = vaultItemClick,
|
||||
folderClick = folderClick,
|
||||
collectionClick = collectionClick,
|
||||
loginGroupClick = loginGroupClick,
|
||||
cardGroupClick = cardGroupClick,
|
||||
identityGroupClick = identityGroupClick,
|
||||
|
|
|
@ -84,6 +84,7 @@ class VaultViewModel @Inject constructor(
|
|||
is VaultAction.AddItemClick -> handleAddItemClick()
|
||||
is VaultAction.CardGroupClick -> handleCardClick()
|
||||
is VaultAction.FolderClick -> handleFolderItemClick(action)
|
||||
is VaultAction.CollectionClick -> handleCollectionItemClick(action)
|
||||
is VaultAction.IdentityGroupClick -> handleIdentityClick()
|
||||
is VaultAction.LoginGroupClick -> handleLoginClick()
|
||||
is VaultAction.SearchIconClick -> handleSearchIconClick()
|
||||
|
@ -118,6 +119,13 @@ class VaultViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun handleCollectionItemClick(action: VaultAction.CollectionClick) {
|
||||
// TODO: Navigate to the listing screen for collections (BIT-406).
|
||||
sendEvent(
|
||||
VaultEvent.ShowToast(message = "Not yet implemented."),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleIdentityClick() {
|
||||
sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.Identity))
|
||||
}
|
||||
|
@ -282,6 +290,7 @@ data class VaultState(
|
|||
* @property favoriteItems The list of favorites to be displayed.
|
||||
* @property folderItems The list of folders to be displayed.
|
||||
* @property noFolderItems The list of non-folders to be displayed.
|
||||
* @property collectionItems The list of collections to be displayed.
|
||||
* @property trashItemsCount The number of items present in the trash.
|
||||
*/
|
||||
@Parcelize
|
||||
|
@ -293,6 +302,7 @@ data class VaultState(
|
|||
val favoriteItems: List<VaultItem>,
|
||||
val folderItems: List<FolderItem>,
|
||||
val noFolderItems: List<VaultItem>,
|
||||
val collectionItems: List<CollectionItem>,
|
||||
val trashItemsCount: Int,
|
||||
) : ViewState()
|
||||
|
||||
|
@ -311,6 +321,20 @@ data class VaultState(
|
|||
val itemCount: Int,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Represents a collection.
|
||||
*
|
||||
* @property id The unique identifier for this collection.
|
||||
* @property name The display name of the collection.
|
||||
* @property itemCount The number of items this collection contains.
|
||||
*/
|
||||
@Parcelize
|
||||
data class CollectionItem(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val itemCount: Int,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* A sealed class hierarchy representing different types of items in the vault.
|
||||
*/
|
||||
|
@ -516,6 +540,13 @@ sealed class VaultAction {
|
|||
val folderItem: VaultState.ViewState.FolderItem,
|
||||
) : VaultAction()
|
||||
|
||||
/**
|
||||
* Action to trigger when a specific collection item is clicked.
|
||||
*/
|
||||
data class CollectionClick(
|
||||
val collectionItem: VaultState.ViewState.CollectionItem,
|
||||
) : VaultAction()
|
||||
|
||||
/**
|
||||
* User clicked the login types button.
|
||||
*/
|
||||
|
|
|
@ -82,6 +82,18 @@ fun VaultData.toViewState(): VaultState.ViewState =
|
|||
noFolderItems = cipherViewList
|
||||
.filter { it.folderId.isNullOrBlank() }
|
||||
.mapNotNull { it.toVaultItemOrNull() },
|
||||
collectionItems = collectionViewList
|
||||
.map { collectionView ->
|
||||
VaultState.ViewState.CollectionItem(
|
||||
id = collectionView.id,
|
||||
name = collectionView.name,
|
||||
itemCount = cipherViewList
|
||||
.count {
|
||||
!it.id.isNullOrBlank() &&
|
||||
collectionView.id in it.collectionIds
|
||||
},
|
||||
)
|
||||
},
|
||||
// TODO need to populate trash item count in BIT-969
|
||||
trashItemsCount = 0,
|
||||
)
|
||||
|
|
18
app/src/main/res/drawable/ic_collection.xml
Normal file
18
app/src/main/res/drawable/ic_collection.xml
Normal file
|
@ -0,0 +1,18 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M3.25,6.375C3.25,6.03 3.53,5.75 3.875,5.75H20.125C20.47,5.75 20.75,6.03 20.75,6.375C20.75,6.72 20.47,7 20.125,7H3.875C3.53,7 3.25,6.72 3.25,6.375Z"
|
||||
android:fillColor="#1B1B1F"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M4.5,3.875C4.5,3.53 4.74,3.25 5.036,3.25H18.964C19.26,3.25 19.5,3.53 19.5,3.875C19.5,4.22 19.26,4.5 18.964,4.5H5.036C4.74,4.5 4.5,4.22 4.5,3.875Z"
|
||||
android:fillColor="#1B1B1F"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M3.875,8.25H20.125C21.16,8.25 22,9.09 22,10.125V20.125C22,21.161 21.16,22 20.125,22H3.875C2.839,22 2,21.161 2,20.125V10.125C2,9.09 2.839,8.25 3.875,8.25ZM3.875,9.5C3.53,9.5 3.25,9.78 3.25,10.125V20.125C3.25,20.47 3.53,20.75 3.875,20.75H20.125C20.47,20.75 20.75,20.47 20.75,20.125V10.125C20.75,9.78 20.47,9.5 20.125,9.5H3.875Z"
|
||||
android:fillColor="#1B1B1F"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
|
@ -25,7 +25,7 @@ fun createMockCipherView(number: Int): CipherView =
|
|||
id = "mockId-$number",
|
||||
organizationId = "mockOrganizationId-$number",
|
||||
folderId = "mockId-$number",
|
||||
collectionIds = listOf("mockCollectionId-$number"),
|
||||
collectionIds = listOf("mockId-$number"),
|
||||
key = "mockKey-$number",
|
||||
name = "mockName-$number",
|
||||
notes = "mockNotes-$number",
|
||||
|
|
|
@ -41,11 +41,14 @@ fun ComposeContentTestRule.assertNoDialogExists() {
|
|||
/**
|
||||
* A helper that asserts that the node does not exist in the scrollable list.
|
||||
*/
|
||||
fun ComposeContentTestRule.assertScrollableNodeDoesNotExist(text: String) {
|
||||
fun ComposeContentTestRule.assertScrollableNodeDoesNotExist(
|
||||
text: String,
|
||||
substring: Boolean = false,
|
||||
) {
|
||||
val scrollableNodeInteraction = onNode(hasScrollToNodeAction())
|
||||
assertThrows<AssertionError> {
|
||||
// throws since it cannot find the node.
|
||||
scrollableNodeInteraction.performScrollToNode(hasText(text))
|
||||
scrollableNodeInteraction.performScrollToNode(hasText(text, substring))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -53,9 +56,12 @@ fun ComposeContentTestRule.assertScrollableNodeDoesNotExist(text: String) {
|
|||
* A helper used to scroll to and get the matching node in a scrollable list. This is intended to
|
||||
* be used with lazy lists that would otherwise fail when calling [performScrollToNode].
|
||||
*/
|
||||
fun ComposeContentTestRule.onNodeWithTextAfterScroll(text: String): SemanticsNodeInteraction {
|
||||
onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text))
|
||||
return onNodeWithText(text)
|
||||
fun ComposeContentTestRule.onNodeWithTextAfterScroll(
|
||||
text: String,
|
||||
substring: Boolean = false,
|
||||
): SemanticsNodeInteraction {
|
||||
onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text, substring))
|
||||
return onNodeWithText(text, substring)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.vault
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
|
@ -15,8 +16,10 @@ import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
|
|||
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
|
||||
import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed
|
||||
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
|
||||
import com.x8bit.bitwarden.ui.util.assertScrollableNodeDoesNotExist
|
||||
import com.x8bit.bitwarden.ui.util.assertSwitcherIsDisplayed
|
||||
import com.x8bit.bitwarden.ui.util.assertSwitcherIsNotDisplayed
|
||||
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
|
||||
import com.x8bit.bitwarden.ui.util.performAccountClick
|
||||
import com.x8bit.bitwarden.ui.util.performAccountIconClick
|
||||
import com.x8bit.bitwarden.ui.util.performAccountLongClick
|
||||
|
@ -319,6 +322,66 @@ class VaultScreenTest : BaseComposeTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collection data should update according to the state`() {
|
||||
val collectionsHeader = "Collections"
|
||||
val collectionsCount = 1
|
||||
val collectionName = "Test Collection"
|
||||
val collectionCount = 3
|
||||
val collectionItem = VaultState.ViewState.CollectionItem(
|
||||
id = "12345",
|
||||
name = collectionName,
|
||||
itemCount = collectionCount,
|
||||
)
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist(collectionsHeader, substring = true)
|
||||
composeTestRule.assertScrollableNodeDoesNotExist(collectionName, substring = true)
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
|
||||
collectionItems = listOf(collectionItem),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(collectionsHeader, substring = true)
|
||||
.assertTextEquals(collectionsHeader, collectionsCount.toString())
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(collectionName)
|
||||
.assertTextEquals(collectionName, collectionCount.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking a collection item should send CollectionClick with the correct item`() {
|
||||
val collectionName = "Test Collection"
|
||||
val collectionCount = 3
|
||||
val collectionItem = VaultState.ViewState.CollectionItem(
|
||||
id = "12345",
|
||||
name = collectionName,
|
||||
itemCount = collectionCount,
|
||||
)
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
|
||||
collectionItems = listOf(collectionItem),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(collectionName))
|
||||
composeTestRule
|
||||
.onNodeWithText(collectionName)
|
||||
.assertTextEquals(collectionName, collectionCount.toString())
|
||||
.performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(VaultAction.CollectionClick(collectionItem))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking a no folder item should send VaultItemClick with the correct item`() {
|
||||
val itemText = "Test Item"
|
||||
|
@ -583,5 +646,6 @@ private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultStat
|
|||
favoriteItems = emptyList(),
|
||||
folderItems = emptyList(),
|
||||
noFolderItems = emptyList(),
|
||||
collectionItems = emptyList(),
|
||||
trashItemsCount = 0,
|
||||
)
|
||||
|
|
|
@ -284,6 +284,13 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
itemCount = 1,
|
||||
),
|
||||
),
|
||||
collectionItems = listOf(
|
||||
VaultState.ViewState.CollectionItem(
|
||||
id = "mockId-1",
|
||||
name = "mockName-1",
|
||||
itemCount = 1,
|
||||
),
|
||||
),
|
||||
noFolderItems = listOf(),
|
||||
trashItemsCount = 0,
|
||||
),
|
||||
|
@ -437,6 +444,22 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CollectionClick should emit ShowToast`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val collectionId = "12345"
|
||||
val collection = mockk<VaultState.ViewState.CollectionItem> {
|
||||
every { id } returns collectionId
|
||||
}
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultAction.CollectionClick(collection))
|
||||
assertEquals(
|
||||
VaultEvent.ShowToast(message = "Not yet implemented."),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IdentityGroupClick should emit NavigateToItemListing event with Identity type`() =
|
||||
runTest {
|
||||
|
|
|
@ -56,6 +56,13 @@ class VaultDataExtensionsTest {
|
|||
itemCount = 1,
|
||||
),
|
||||
),
|
||||
collectionItems = listOf(
|
||||
VaultState.ViewState.CollectionItem(
|
||||
id = "mockId-1",
|
||||
name = "mockName-1",
|
||||
itemCount = 1,
|
||||
),
|
||||
),
|
||||
noFolderItems = listOf(),
|
||||
trashItemsCount = 0,
|
||||
),
|
||||
|
@ -103,6 +110,13 @@ class VaultDataExtensionsTest {
|
|||
itemCount = 0,
|
||||
),
|
||||
),
|
||||
collectionItems = listOf(
|
||||
VaultState.ViewState.CollectionItem(
|
||||
id = "mockId-1",
|
||||
name = "mockName-1",
|
||||
itemCount = 0,
|
||||
),
|
||||
),
|
||||
noFolderItems = emptyList(),
|
||||
trashItemsCount = 0,
|
||||
),
|
||||
|
|
Loading…
Add table
Reference in a new issue