mirror of
https://github.com/bitwarden/android.git
synced 2026-06-03 11:16:40 -05:00
BIT-2009 Add support for nested collections (#1111)
This commit is contained in:
committed by
Álison Fernandes
parent
5e1328eecb
commit
3b33360e58
@@ -39,6 +39,7 @@ import kotlinx.collections.immutable.toPersistentList
|
||||
fun VaultItemListingContent(
|
||||
state: VaultItemListingState.ViewState.Content,
|
||||
policyDisablesSend: Boolean,
|
||||
collectionClick: (id: String) -> Unit,
|
||||
folderClick: (id: String) -> Unit,
|
||||
vaultItemClick: (id: String) -> Unit,
|
||||
masterPasswordRepromptSubmit: (password: String, data: MasterPasswordRepromptData) -> Unit,
|
||||
@@ -111,6 +112,35 @@ fun VaultItemListingContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.displayCollectionList.isNotEmpty()) {
|
||||
item {
|
||||
BitwardenListHeaderTextWithSupportLabel(
|
||||
label = stringResource(id = R.string.collections),
|
||||
supportingLabel = state.displayCollectionList.count().toString(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
items(state.displayCollectionList) { collection ->
|
||||
BitwardenGroupItem(
|
||||
startIcon = painterResource(id = R.drawable.ic_collection),
|
||||
label = collection.name,
|
||||
supportingLabel = collection.count.toString(),
|
||||
onClick = { collectionClick(collection.id) },
|
||||
showDivider = false,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.displayFolderList.isNotEmpty()) {
|
||||
item {
|
||||
BitwardenListHeaderTextWithSupportLabel(
|
||||
@@ -140,7 +170,7 @@ fun VaultItemListingContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.displayItemList.isNotEmpty() && state.displayFolderList.isNotEmpty()) {
|
||||
if (state.shouldShowDivider) {
|
||||
item {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
|
||||
@@ -56,7 +56,7 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
fun VaultItemListingScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToVaultItem: (id: String) -> Unit,
|
||||
@@ -124,6 +124,10 @@ fun VaultItemListingScreen(
|
||||
is VaultItemListingEvent.NavigateToFolderItem -> {
|
||||
onNavigateToVaultItemListing(VaultItemListingType.Folder(event.folderId))
|
||||
}
|
||||
|
||||
is VaultItemListingEvent.NavigateToCollectionItem -> {
|
||||
onNavigateToVaultItemListing(VaultItemListingType.Collection(event.collectionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +248,7 @@ private fun VaultItemListingScaffold(
|
||||
policyDisablesSend = state.policyDisablesSend &&
|
||||
state.itemListingType is VaultItemListingState.ItemListingType.Send,
|
||||
vaultItemClick = vaultItemListingHandlers.itemClick,
|
||||
collectionClick = vaultItemListingHandlers.collectionClick,
|
||||
folderClick = vaultItemListingHandlers.folderClick,
|
||||
masterPasswordRepromptSubmit =
|
||||
vaultItemListingHandlers.masterPasswordRepromptSubmit,
|
||||
|
||||
@@ -139,6 +139,7 @@ class VaultItemListingViewModel @Inject constructor(
|
||||
is VaultItemListingsAction.DismissDialogClick -> handleDismissDialogClick()
|
||||
is VaultItemListingsAction.BackClick -> handleBackClick()
|
||||
is VaultItemListingsAction.FolderClick -> handleFolderClick(action)
|
||||
is VaultItemListingsAction.CollectionClick -> handleCollectionClick(action)
|
||||
is VaultItemListingsAction.LockClick -> handleLockClick()
|
||||
is VaultItemListingsAction.SyncClick -> handleSyncClick()
|
||||
is VaultItemListingsAction.SearchIconClick -> handleSearchIconClick()
|
||||
@@ -168,6 +169,10 @@ class VaultItemListingViewModel @Inject constructor(
|
||||
authRepository.switchAccount(userId = action.accountSummary.userId)
|
||||
}
|
||||
|
||||
private fun handleCollectionClick(action: VaultItemListingsAction.CollectionClick) {
|
||||
sendEvent(VaultItemListingEvent.NavigateToCollectionItem(action.id))
|
||||
}
|
||||
|
||||
private fun handleFolderClick(action: VaultItemListingsAction.FolderClick) {
|
||||
sendEvent(VaultItemListingEvent.NavigateToFolderItem(action.id))
|
||||
}
|
||||
@@ -839,12 +844,18 @@ data class VaultItemListingState(
|
||||
* Content state for the [VaultItemListingScreen] showing the actual content or items.
|
||||
*
|
||||
* @property displayItemList List of items to display.
|
||||
* @property displayFolderList list of folders to display.
|
||||
* @property displayCollectionList list of collections to display.
|
||||
*/
|
||||
data class Content(
|
||||
val displayItemList: List<DisplayItem>,
|
||||
val displayFolderList: List<FolderDisplayItem>,
|
||||
val displayCollectionList: List<CollectionDisplayItem>,
|
||||
) : ViewState() {
|
||||
override val isPullToRefreshEnabled: Boolean get() = true
|
||||
val shouldShowDivider: Boolean
|
||||
get() = displayItemList.isNotEmpty() &&
|
||||
(displayFolderList.isNotEmpty() || displayCollectionList.isNotEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -895,6 +906,19 @@ data class VaultItemListingState(
|
||||
val count: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* The collection that is displayed to the user on the ItemListingScreen.
|
||||
*
|
||||
* @property id the id of the collection.
|
||||
* @property name the name of the collection.
|
||||
* @property count the amount of ciphers in the collection.
|
||||
*/
|
||||
data class CollectionDisplayItem(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val count: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents different types of item listing.
|
||||
*/
|
||||
@@ -1031,6 +1055,11 @@ sealed class VaultItemListingEvent {
|
||||
*/
|
||||
data object NavigateToAddVaultItem : VaultItemListingEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the collection.
|
||||
*/
|
||||
data class NavigateToCollectionItem(val collectionId: String) : VaultItemListingEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the folder.
|
||||
*/
|
||||
@@ -1165,6 +1194,13 @@ sealed class VaultItemListingsAction {
|
||||
*/
|
||||
data class ItemClick(val id: String) : VaultItemListingsAction()
|
||||
|
||||
/**
|
||||
* Click on the collection.
|
||||
*
|
||||
* @property id the id of the collection that has been clicked
|
||||
*/
|
||||
data class CollectionClick(val id: String) : VaultItemListingsAction()
|
||||
|
||||
/**
|
||||
* Click on the folder.
|
||||
*
|
||||
|
||||
@@ -19,6 +19,7 @@ data class VaultItemListingHandlers(
|
||||
val addVaultItemClick: () -> Unit,
|
||||
val itemClick: (id: String) -> Unit,
|
||||
val folderClick: (id: String) -> Unit,
|
||||
val collectionClick: (id: String) -> Unit,
|
||||
val masterPasswordRepromptSubmit: (password: String, MasterPasswordRepromptData) -> Unit,
|
||||
val refreshClick: () -> Unit,
|
||||
val syncClick: () -> Unit,
|
||||
@@ -50,6 +51,9 @@ data class VaultItemListingHandlers(
|
||||
addVaultItemClick = {
|
||||
viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick)
|
||||
},
|
||||
collectionClick = {
|
||||
viewModel.trySendAction(VaultItemListingsAction.CollectionClick(it))
|
||||
},
|
||||
itemClick = { viewModel.trySendAction(VaultItemListingsAction.ItemClick(it)) },
|
||||
folderClick = { viewModel.trySendAction(VaultItemListingsAction.FolderClick(it)) },
|
||||
masterPasswordRepromptSubmit = { password, data ->
|
||||
|
||||
@@ -19,7 +19,9 @@ import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.util.toLabelIcons
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.util.toOverflowActions
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
|
||||
import com.x8bit.bitwarden.ui.vault.feature.util.getCollections
|
||||
import com.x8bit.bitwarden.ui.vault.feature.util.getFolders
|
||||
import com.x8bit.bitwarden.ui.vault.feature.util.toCollectionDisplayName
|
||||
import com.x8bit.bitwarden.ui.vault.feature.util.toFolderDisplayName
|
||||
import com.x8bit.bitwarden.ui.vault.feature.util.toLabelIcons
|
||||
import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions
|
||||
@@ -101,15 +103,20 @@ fun VaultData.toViewState(
|
||||
}
|
||||
.toFilteredList(vaultFilterType)
|
||||
|
||||
val folderList = if (itemListingType is VaultItemListingState.ItemListingType.Vault.Folder &&
|
||||
!itemListingType.folderId.isNullOrBlank()
|
||||
) {
|
||||
folderViewList.getFolders(itemListingType.folderId)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val folderList =
|
||||
(itemListingType as? VaultItemListingState.ItemListingType.Vault.Folder)
|
||||
?.folderId
|
||||
?.let { folderViewList.getFolders(it) }
|
||||
.orEmpty()
|
||||
|
||||
return if (folderList.isNotEmpty() || filteredCipherViewList.isNotEmpty()) {
|
||||
val collectionList =
|
||||
(itemListingType as? VaultItemListingState.ItemListingType.Vault.Collection)
|
||||
?.let { collectionViewList.getCollections(it.collectionId) }
|
||||
.orEmpty()
|
||||
|
||||
return if (folderList.isNotEmpty() || filteredCipherViewList.isNotEmpty() ||
|
||||
collectionList.isNotEmpty()
|
||||
) {
|
||||
VaultItemListingState.ViewState.Content(
|
||||
displayItemList = filteredCipherViewList.toDisplayItemList(
|
||||
baseIconUrl = baseIconUrl,
|
||||
@@ -128,6 +135,18 @@ fun VaultData.toViewState(
|
||||
},
|
||||
)
|
||||
},
|
||||
displayCollectionList = collectionList.map { collectionView ->
|
||||
VaultItemListingState.CollectionDisplayItem(
|
||||
id = requireNotNull(collectionView.id),
|
||||
name = collectionView.name,
|
||||
count = this.cipherViewList
|
||||
.count {
|
||||
!it.id.isNullOrBlank() &&
|
||||
it.deletedDate == null &&
|
||||
collectionView.id in it.collectionIds
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// Use the autofill empty message if necessary, otherwise use normal type-specific message
|
||||
@@ -141,6 +160,10 @@ fun VaultData.toViewState(
|
||||
R.string.no_items_folder
|
||||
}
|
||||
|
||||
is VaultItemListingState.ItemListingType.Vault.Collection -> {
|
||||
R.string.no_items_collection
|
||||
}
|
||||
|
||||
VaultItemListingState.ItemListingType.Vault.Trash -> {
|
||||
R.string.no_items_trash
|
||||
}
|
||||
@@ -177,6 +200,7 @@ fun List<SendView>.toViewState(
|
||||
clock = clock,
|
||||
),
|
||||
displayFolderList = emptyList(),
|
||||
displayCollectionList = emptyList(),
|
||||
)
|
||||
} else {
|
||||
VaultItemListingState.ViewState.NoItems(
|
||||
@@ -196,6 +220,7 @@ fun VaultItemListingState.ItemListingType.updateWithAdditionalDataIfNecessary(
|
||||
collectionName = collectionList
|
||||
.find { it.id == collectionId }
|
||||
?.name
|
||||
?.toCollectionDisplayName(collectionList)
|
||||
.orEmpty(),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.util
|
||||
|
||||
import com.bitwarden.core.CollectionView
|
||||
|
||||
private const val COLLECTION_DIVIDER: String = "/"
|
||||
|
||||
/**
|
||||
* Retrieves the nested collections of a given [collectionId] and updates their names to proper
|
||||
* display names. This function is necessary if we want to show the nested collections for a
|
||||
* specific collection.
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
fun List<CollectionView>.getCollections(collectionId: String): List<CollectionView> {
|
||||
val currentCollection = this.find { it.id == collectionId } ?: return emptyList()
|
||||
|
||||
// If two collections have the same name the second collection should have no nested collections
|
||||
val firstCollectionWithName = this.first { it.name == currentCollection.name }
|
||||
if (firstCollectionWithName.id != collectionId) return emptyList()
|
||||
|
||||
val collectionList = this
|
||||
.getFilteredCollections(currentCollection.name)
|
||||
.map {
|
||||
it.copy(name = it.name.substringAfter(currentCollection.name + COLLECTION_DIVIDER))
|
||||
}
|
||||
|
||||
return collectionList
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out the nest collections of the nest collections from the given list. If a
|
||||
* [collectionName] is provided, collections that are not nested of the specified [collectionName]
|
||||
* will be filtered out.
|
||||
*/
|
||||
fun List<CollectionView>.getFilteredCollections(
|
||||
collectionName: String? = null,
|
||||
): List<CollectionView> =
|
||||
this.filter { collectionView ->
|
||||
// If the collection name is not null we filter out collections that are not nested
|
||||
// collections.
|
||||
if (collectionName != null &&
|
||||
!collectionView.name.startsWith(collectionName + COLLECTION_DIVIDER)
|
||||
) {
|
||||
return@filter false
|
||||
}
|
||||
|
||||
this.forEach {
|
||||
val firstCollection = collectionName
|
||||
?.let { name -> collectionView.name.substringAfter(name + COLLECTION_DIVIDER) }
|
||||
?: collectionView.name
|
||||
|
||||
val secondCollection = collectionName
|
||||
?.let { name -> it.name.substringAfter(name + COLLECTION_DIVIDER) }
|
||||
?: it.name
|
||||
|
||||
// We don't want to compare the collection to itself or itself plus a slash.
|
||||
if (firstCollection == secondCollection) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
// If the first collection name is blank or the first collection is a nested collection
|
||||
// of the second collection, we want to filter it out.
|
||||
if (firstCollection.isEmpty() ||
|
||||
firstCollection.startsWith(secondCollection + COLLECTION_DIVIDER)
|
||||
) {
|
||||
return@filter false
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a collection name to a user-friendly display name. This function is necessary because
|
||||
* the collection name we receive is often nested, and we want to extract just the relevant name for
|
||||
* display to the user.
|
||||
*/
|
||||
fun String.toCollectionDisplayName(list: List<CollectionView>): String {
|
||||
var collectionName = this
|
||||
|
||||
// cycle through the list and determine the correct display name of the collection.
|
||||
list.forEach { collection ->
|
||||
if (this.startsWith(collection.name + COLLECTION_DIVIDER)) {
|
||||
val newName = this.substringAfter(collection.name + COLLECTION_DIVIDER)
|
||||
if (newName.length < collectionName.length) {
|
||||
collectionName = newName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collectionName
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.util.getFilteredCollections
|
||||
import com.x8bit.bitwarden.ui.vault.feature.util.getFilteredFolders
|
||||
import com.x8bit.bitwarden.ui.vault.feature.util.toLabelIcons
|
||||
import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions
|
||||
@@ -47,7 +48,10 @@ fun VaultData.toViewState(
|
||||
|
||||
val filteredFolderViewList = folderViewList.toFilteredList(vaultFilterType).getFilteredFolders()
|
||||
|
||||
val filteredCollectionViewList = collectionViewList.toFilteredList(vaultFilterType)
|
||||
val filteredCollectionViewList = collectionViewList
|
||||
.toFilteredList(vaultFilterType)
|
||||
.getFilteredCollections()
|
||||
|
||||
val noFolderItems = filteredCipherViewList
|
||||
.filter { it.folderId.isNullOrBlank() }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user