From 924c851b571d782345bbf5a33d773ca09d826e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Mon, 26 Oct 2026 21:18:28 +0000 Subject: [PATCH] [PM-35396] feat: Add expand/collapse to vault section headers (#7504) --- .../datasource/disk/SettingsDiskSource.kt | 22 + .../datasource/disk/SettingsDiskSourceImpl.kt | 39 + .../platform/repository/SettingsRepository.kt | 16 + .../repository/SettingsRepositoryImpl.kt | 28 + .../platform/repository/model/VaultSection.kt | 45 + .../ui/vault/feature/vault/VaultContent.kt | 982 ++++++++++-------- .../ui/vault/feature/vault/VaultScreen.kt | 1 + .../ui/vault/feature/vault/VaultViewModel.kt | 40 + .../feature/vault/handlers/VaultHandlers.kt | 5 + .../datasource/disk/SettingsDiskSourceTest.kt | 70 ++ .../disk/util/FakeSettingsDiskSource.kt | 36 + .../repository/SettingsRepositoryTest.kt | 64 ++ .../ui/vault/feature/vault/VaultScreenTest.kt | 111 +- .../vault/feature/vault/VaultViewModelTest.kt | 32 + .../feature/itemlisting/ItemListingScreen.kt | 17 +- .../feature/search/ItemSearchContent.kt | 1 + .../SharedVerificationCodesStateExtensions.kt | 1 - .../itemlisting/ItemListingScreenTest.kt | 4 +- .../feature/search/ItemSearchViewModelTest.kt | 1 - .../util/SharedVerificationCodesStateTest.kt | 4 - ...SharedCodesAccountSectionExtensionsTest.kt | 4 - .../header/BitwardenExpandingHeader.kt | 68 +- .../header/BitwardenExpandingListHeader.kt | 96 ++ .../components/header/ExpandingHeaderRow.kt | 90 ++ ui/src/main/res/values/strings.xml | 1 + .../main/res/values/strings_non_localized.xml | 2 +- 26 files changed, 1240 insertions(+), 540 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/model/VaultSection.kt create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/BitwardenExpandingListHeader.kt create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/ExpandingHeaderRow.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt index 30fb08e6e4..fff1df739b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -5,6 +5,7 @@ import com.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import kotlinx.coroutines.flow.Flow import java.time.Instant @@ -134,6 +135,27 @@ interface SettingsDiskSource : FlightRecorderDiskSource { */ fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow + /** + * Retrieves the stored set of collapsed vault sections for the given [userId]. + * + * Returns `null` if no value has been stored, which is equivalent to every section being + * expanded. Stored values that do not match a known [VaultSection] are skipped. + */ + fun getCollapsedVaultSections(userId: String): Set? + + /** + * Stores the given set of [collapsedVaultSections] for the given [userId]. + */ + fun storeCollapsedVaultSections( + userId: String, + collapsedVaultSections: Set?, + ) + + /** + * Emits updates that track [getCollapsedVaultSections] for the given [userId]. + */ + fun getCollapsedVaultSectionsFlow(userId: String): Flow?> + /** * Retrieves the stored value of whether the Premium upgrade banner has been dismissed. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index f52e81da72..047d6f766e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -10,6 +10,7 @@ import com.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -54,6 +55,7 @@ private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogRe private const val INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED = "introducingArchiveActionCardDismissed" private const val PREMIUM_UPGRADE_BANNER_DISMISSED = "premiumUpgradeBannerDismissed" +private const val COLLAPSED_VAULT_SECTIONS = "collapsedVaultSections" private const val VAULT_POLICY_BANNER_DISMISSED_DATE = "vaultPolicyBannerDismissedDate" private const val UPGRADED_TO_PREMIUM_CARD_CONSUMED = "upgradedToPremiumCardConsumed" private const val UPGRADED_TO_PREMIUM_CARD_PENDING = "upgradedToPremiumCardPending" @@ -105,6 +107,9 @@ class SettingsDiskSourceImpl( private val mutablePremiumUpgradeBannerDismissedFlowMap = mutableMapOf>() + private val mutableCollapsedVaultSectionsFlowMap = + mutableMapOf?>>() + private val mutableVaultPolicyBannerDismissedDateFlowMap = mutableMapOf>() @@ -277,6 +282,7 @@ class SettingsDiskSourceImpl( removeWithPrefix(prefix = ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY.appendIdentifier(userId)) storeAppResumeScreen(userId = userId, screenData = null) storeVaultPolicyBannerDismissedDate(userId = userId, dismissalRevisionDate = null) + storeCollapsedVaultSections(userId = userId, collapsedVaultSections = null) // The following are intentionally not cleared so they can be // restored after logging out and back in: @@ -313,6 +319,32 @@ class SettingsDiskSourceImpl( getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId) .onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) } + override fun getCollapsedVaultSections(userId: String): Set? = + getString(key = COLLAPSED_VAULT_SECTIONS.appendIdentifier(identifier = userId)) + ?.let { json.decodeFromStringOrNull>(it) } + ?.mapNotNull { storedValue -> + // Unknown values are skipped so the remaining sections are preserved. + VaultSection.entries.firstOrNull { it.value == storedValue } + } + ?.toSet() + + override fun storeCollapsedVaultSections( + userId: String, + collapsedVaultSections: Set?, + ) { + putString( + key = COLLAPSED_VAULT_SECTIONS.appendIdentifier(identifier = userId), + value = collapsedVaultSections?.let { sections -> + json.encodeToString(sections.map { it.value }.toSet()) + }, + ) + getMutableCollapsedVaultSectionsFlow(userId = userId).tryEmit(collapsedVaultSections) + } + + override fun getCollapsedVaultSectionsFlow(userId: String): Flow?> = + getMutableCollapsedVaultSectionsFlow(userId = userId) + .onSubscription { emit(getCollapsedVaultSections(userId = userId)) } + override fun getPremiumUpgradeBannerDismissed(userId: String): Boolean? = getBoolean( key = PREMIUM_UPGRADE_BANNER_DISMISSED.appendIdentifier(identifier = userId), @@ -774,6 +806,13 @@ class SettingsDiskSourceImpl( bufferedMutableSharedFlow(replay = 1) } + private fun getMutableCollapsedVaultSectionsFlow( + userId: String, + ): MutableSharedFlow?> = + mutableCollapsedVaultSectionsFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + private fun getMutablePremiumUpgradeBannerDismissedFlow( userId: String, ): MutableSharedFlow = diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt index 2cb33492d7..d41f5cd312 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import kotlinx.coroutines.flow.Flow @@ -278,6 +279,21 @@ interface SettingsRepository : FlightRecorderManager { */ fun dismissIntroducingArchiveActionCard() + /** + * The set of collapsed vault sections for the active user. + */ + val collapsedVaultSections: Set + + /** + * Gets updates for the set of collapsed vault sections for the active user. + */ + fun getCollapsedVaultSectionsFlow(): Flow> + + /** + * Toggles whether the given [section] is collapsed for the active user. + */ + fun toggleVaultSectionCollapsed(section: VaultSection) + /** * Gets updates for whether the Premium upgrade banner is dismissed. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index 7b28a84268..3b9c3beb37 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource @@ -568,6 +569,33 @@ class SettingsRepositoryImpl( } } + override val collapsedVaultSections: Set + get() = activeUserId + ?.let { settingsDiskSource.getCollapsedVaultSections(userId = it) } + .orEmpty() + + override fun getCollapsedVaultSectionsFlow(): Flow> { + val userId = activeUserId ?: return flowOf(emptySet()) + return settingsDiskSource + .getCollapsedVaultSectionsFlow(userId = userId) + .map { it.orEmpty() } + } + + override fun toggleVaultSectionCollapsed(section: VaultSection) { + val userId = activeUserId ?: return + val collapsedSections = settingsDiskSource + .getCollapsedVaultSections(userId = userId) + .orEmpty() + settingsDiskSource.storeCollapsedVaultSections( + userId = userId, + collapsedVaultSections = if (section in collapsedSections) { + collapsedSections - section + } else { + collapsedSections + section + }, + ) + } + override fun getPremiumUpgradeBannerDismissedFlow(): StateFlow { val userId = activeUserId ?: return MutableStateFlow(value = false) return settingsDiskSource diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/model/VaultSection.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/model/VaultSection.kt new file mode 100644 index 0000000000..1d385b84f1 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/model/VaultSection.kt @@ -0,0 +1,45 @@ +package com.x8bit.bitwarden.data.platform.repository.model + +/** + * Represents a collapsible section on the vault screen. + * + * The [value] is used for consistent storage purposes. + */ +enum class VaultSection( + val value: String, +) { + /** + * The section containing the verification codes group. + */ + TOTP(value = "totp"), + + /** + * The section containing the favorite items. + */ + FAVORITES(value = "favorites"), + + /** + * The section containing the item type groups. + */ + TYPES(value = "types"), + + /** + * The section containing the folders. + */ + FOLDERS(value = "folders"), + + /** + * The section containing the items that are not in a folder. + */ + NO_FOLDER(value = "noFolder"), + + /** + * The section containing the collections. + */ + COLLECTIONS(value = "collections"), + + /** + * The section containing the archive and trash groups. + */ + HIDDEN_ITEMS(value = "hiddenItems"), +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index 47d4e7ad0c..6f579c3fca 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -1,11 +1,12 @@ package com.x8bit.bitwarden.ui.vault.feature.vault +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -19,16 +20,18 @@ 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.dialog.BitwardenTwoButtonDialog -import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText +import com.bitwarden.ui.platform.components.header.BitwardenExpandingListHeader import com.bitwarden.ui.platform.components.icon.model.IconData import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.components.listitem.BitwardenGroupItem import com.x8bit.bitwarden.ui.platform.composition.util.vfo1Foundation import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers +import kotlinx.collections.immutable.ImmutableSet private const val TOTP_TYPES_COUNT: Int = 1 private const val HIDDEN_TYPES_COUNT: Int = 2 @@ -37,10 +40,11 @@ private const val HIDDEN_TYPES_COUNT: Int = 2 * Content view for the [VaultScreen]. */ @Composable -@Suppress("LongMethod", "CyclomaticComplexMethod") +@Suppress("LongMethod") fun VaultContent( state: VaultState.ViewState.Content, actionCardState: VaultState.ActionCardState?, + collapsedSections: ImmutableSet, vaultHandlers: VaultHandlers, modifier: Modifier = Modifier, ) { @@ -95,15 +99,39 @@ fun VaultContent( } } + val onVaultItemClick: (VaultState.ViewState.VaultItem) -> Unit = { item -> + if (item.shouldShowMasterPasswordReprompt) { + masterPasswordRepromptItem = item + } else { + vaultHandlers.vaultItemClick(item) + } + } + val onVaultItemOverflowOptionClick: ( + VaultState.ViewState.VaultItem, + ListingItemOverflowAction.VaultAction, + ) -> Unit = { item, action -> + if (item.shouldShowMasterPasswordReprompt && action.requiresPasswordReprompt) { + overflowMasterPasswordRepromptAction = action + } else if (action.speedBump != null) { + overflowSpeedBumpAction = action + } else { + vaultHandlers.overflowOptionClick(action) + } + } + val foldersLabelRes = vfo1Foundation( + new = BitwardenString.my_folders, + old = BitwardenString.folders, + ) + val collectionsLabelRes = vfo1Foundation( + new = BitwardenString.shared_folders, + old = BitwardenString.collections, + ) LazyColumn( modifier = modifier, ) { - item(key = "top_spacer") { - Spacer(modifier = Modifier.height(height = 12.dp)) - } - actionCardState?.let { item(key = "action_card") { + Spacer(modifier = Modifier.height(height = 12.dp)) VaultActionCard( actionCardState = it, vaultHandlers = vaultHandlers, @@ -112,457 +140,56 @@ fun VaultContent( .fillMaxWidth() .standardHorizontalMargin(), ) - Spacer(modifier = Modifier.height(height = 24.dp)) } } - if (state.totpItemsCount > 0) { - item(key = "totp_header") { - BitwardenListHeaderText( - label = stringResource(id = BitwardenString.totp), - supportingLabel = TOTP_TYPES_COUNT.toString(), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .standardHorizontalMargin() - .padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(height = 8.dp)) - } + totpSection( + state = state, + collapsedSections = collapsedSections, + vaultHandlers = vaultHandlers, + ) - item(key = "verification_codes_group") { - BitwardenGroupItem( - startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_clock), - label = stringResource(id = BitwardenString.verification_codes), - supportingLabel = state.totpItemsCount.toString(), - onClick = vaultHandlers.verificationCodesClick, - cardStyle = CardStyle.Full, - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("VerificationCodesFilter") - .standardHorizontalMargin(), - ) - Spacer(modifier = Modifier.height(height = 16.dp)) - } - } + favoritesSection( + state = state, + collapsedSections = collapsedSections, + vaultHandlers = vaultHandlers, + onVaultItemClick = onVaultItemClick, + onVaultItemOverflowOptionClick = onVaultItemOverflowOptionClick, + ) - if (state.favoriteItems.isNotEmpty()) { - item(key = "favorites_header") { - BitwardenListHeaderText( - label = stringResource(id = BitwardenString.favorites), - supportingLabel = state.favoriteItems.count().toString(), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .standardHorizontalMargin() - .padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(height = 8.dp)) - } + typesSection( + state = state, + collapsedSections = collapsedSections, + vaultHandlers = vaultHandlers, + ) - itemsIndexed( - items = state.favoriteItems, - key = { _, favorite -> "favorite_${favorite.id}" }, - ) { index, favoriteItem -> - VaultEntryListItem( - startIcon = favoriteItem.startIcon, - startIconTestTag = favoriteItem.startIconTestTag, - trailingLabelIcons = favoriteItem.extraIconList, - label = favoriteItem.name(), - supportingLabel = favoriteItem.supportingLabel?.invoke(), - onClick = { - if (favoriteItem.shouldShowMasterPasswordReprompt) { - masterPasswordRepromptItem = favoriteItem - } else { - vaultHandlers.vaultItemClick(favoriteItem) - } - }, - overflowOptions = favoriteItem.overflowOptions, - onOverflowOptionClick = { action -> - if (favoriteItem.shouldShowMasterPasswordReprompt && - action.requiresPasswordReprompt - ) { - overflowMasterPasswordRepromptAction = action - } else if (action.speedBump != null) { - overflowSpeedBumpAction = action - } else { - vaultHandlers.overflowOptionClick(action) - } - }, - cardStyle = state - .favoriteItems - .toListItemCardStyle(index = index, dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("CipherCell") - .standardHorizontalMargin(), - ) - } - item(key = "favorites_spacer") { - Spacer(modifier = Modifier.height(height = 16.dp)) - } - } + foldersSection( + state = state, + collapsedSections = collapsedSections, + vaultHandlers = vaultHandlers, + labelRes = foldersLabelRes, + ) - item(key = "types_header") { - BitwardenListHeaderText( - label = stringResource(id = BitwardenString.types), - supportingLabel = state.itemTypesCount.toString(), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .standardHorizontalMargin() - .padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(height = 8.dp)) - } + noFolderSection( + state = state, + collapsedSections = collapsedSections, + vaultHandlers = vaultHandlers, + onVaultItemClick = onVaultItemClick, + onVaultItemOverflowOptionClick = onVaultItemOverflowOptionClick, + ) - item(key = "logins_group") { - BitwardenGroupItem( - startIcon = IconData.Local( - iconRes = BitwardenDrawable.ic_globe, - testTag = "LoginCipherIcon", - ), - label = stringResource(id = BitwardenString.type_login), - supportingLabel = state.loginItemsCount.toString(), - onClick = vaultHandlers.loginGroupClick, - cardStyle = CardStyle.Top(dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("LoginFilter") - .standardHorizontalMargin(), - ) - } + collectionsSection( + state = state, + collapsedSections = collapsedSections, + vaultHandlers = vaultHandlers, + labelRes = collectionsLabelRes, + ) - if (state.showCardGroup) { - item(key = "cards_group") { - BitwardenGroupItem( - startIcon = IconData.Local( - iconRes = BitwardenDrawable.ic_payment_card, - testTag = "CardCipherIcon", - ), - label = stringResource(id = BitwardenString.type_card), - supportingLabel = state.cardItemsCount.toString(), - onClick = vaultHandlers.cardGroupClick, - cardStyle = CardStyle.Middle(dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("CardFilter") - .standardHorizontalMargin(), - ) - } - } - - if (state.showBankAccountGroup) { - item(key = "bank_accounts_group") { - BitwardenGroupItem( - startIcon = IconData.Local( - iconRes = BitwardenDrawable.ic_payment_card, - testTag = "BankAccountCipherIcon", - ), - label = stringResource(id = BitwardenString.type_bank_account), - supportingLabel = state.bankAccountItemsCount.toString(), - onClick = vaultHandlers.bankAccountGroupClick, - cardStyle = CardStyle.Middle(dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("BankAccountFilter") - .standardHorizontalMargin(), - ) - } - } - - item(key = "identities_group") { - BitwardenGroupItem( - startIcon = IconData.Local( - iconRes = BitwardenDrawable.ic_id_card, - testTag = "IdentityCipherIcon", - ), - label = stringResource(id = BitwardenString.type_identity), - supportingLabel = state.identityItemsCount.toString(), - onClick = vaultHandlers.identityGroupClick, - cardStyle = CardStyle.Middle(dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("IdentityFilter") - .standardHorizontalMargin(), - ) - } - - if (state.showLicenseGroup) { - item(key = "licenses_group") { - BitwardenGroupItem( - startIcon = IconData.Local( - iconRes = BitwardenDrawable.ic_id_card, - testTag = "LicenseCipherIcon", - ), - label = stringResource(id = BitwardenString.type_license), - supportingLabel = state.licenseItemsCount.toString(), - onClick = vaultHandlers.licenseGroupClick, - cardStyle = CardStyle.Middle(dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("LicenseFilter") - .standardHorizontalMargin(), - ) - } - } - - if (state.showPassportGroup) { - item(key = "passports_group") { - BitwardenGroupItem( - startIcon = IconData.Local( - iconRes = BitwardenDrawable.ic_passport, - testTag = "PassportCipherIcon", - ), - label = stringResource(id = BitwardenString.type_passport), - supportingLabel = state.passportItemsCount.toString(), - onClick = vaultHandlers.passportGroupClick, - cardStyle = CardStyle.Middle(dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("PassportFilter") - .standardHorizontalMargin(), - ) - } - } - - item(key = "notes_group") { - BitwardenGroupItem( - startIcon = IconData.Local( - iconRes = BitwardenDrawable.ic_note, - testTag = "SecureNoteCipherIcon", - ), - label = stringResource(id = BitwardenString.type_secure_note), - supportingLabel = state.secureNoteItemsCount.toString(), - onClick = vaultHandlers.secureNoteGroupClick, - cardStyle = CardStyle.Middle(dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("SecureNoteFilter") - .standardHorizontalMargin(), - ) - } - - item(key = "ssh_keys_group") { - BitwardenGroupItem( - startIcon = IconData.Local( - iconRes = BitwardenDrawable.ic_ssh_key, - testTag = "SshKeyCipherIcon", - ), - label = stringResource(id = BitwardenString.type_ssh_key), - supportingLabel = state.sshKeyItemsCount.toString(), - onClick = vaultHandlers.sshKeyGroupClick, - cardStyle = CardStyle.Bottom, - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("SshKeyFilter") - .standardHorizontalMargin(), - ) - } - - item(key = "types_spacer") { - Spacer(modifier = Modifier.height(height = 16.dp)) - } - - if (state.folderItems.isNotEmpty()) { - item(key = "folders_header") { - BitwardenListHeaderText( - label = stringResource( - id = vfo1Foundation(BitwardenString.my_folders, BitwardenString.folders), - ), - supportingLabel = state.folderItems.count().toString(), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .standardHorizontalMargin() - .padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(height = 8.dp)) - } - - itemsIndexed( - items = state.folderItems, - key = { _, folder -> "folder_${folder.id}" }, - ) { index, folder -> - BitwardenGroupItem( - startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_folder), - label = folder.name(), - supportingLabel = folder.itemCount.toString(), - onClick = { vaultHandlers.folderClick(folder) }, - cardStyle = state - .folderItems - .toListItemCardStyle(index = index, dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("FolderFilter") - .standardHorizontalMargin(), - ) - } - item(key = "folders_spacer") { - Spacer(modifier = Modifier.height(height = 16.dp)) - } - } - - if (state.noFolderItems.isNotEmpty()) { - item(key = "no_folders_header") { - BitwardenListHeaderText( - label = stringResource(id = BitwardenString.folder_none), - supportingLabel = state.noFolderItems.count().toString(), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .standardHorizontalMargin() - .padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(height = 8.dp)) - } - itemsIndexed( - items = state.noFolderItems, - key = { _, noFolderItem -> "no_folder_${noFolderItem.id}" }, - ) { index, noFolderItem -> - VaultEntryListItem( - startIcon = noFolderItem.startIcon, - startIconTestTag = noFolderItem.startIconTestTag, - trailingLabelIcons = noFolderItem.extraIconList, - label = noFolderItem.name(), - supportingLabel = noFolderItem.supportingLabel?.invoke(), - onClick = { - if (noFolderItem.shouldShowMasterPasswordReprompt) { - masterPasswordRepromptItem = noFolderItem - } else { - vaultHandlers.vaultItemClick(noFolderItem) - } - }, - overflowOptions = noFolderItem.overflowOptions, - onOverflowOptionClick = { action -> - if (noFolderItem.shouldShowMasterPasswordReprompt && - action.requiresPasswordReprompt - ) { - overflowMasterPasswordRepromptAction = action - } else if (action.speedBump != null) { - overflowSpeedBumpAction = action - } else { - vaultHandlers.overflowOptionClick(action) - } - }, - cardStyle = state - .noFolderItems - .toListItemCardStyle(index = index, dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("CipherCell") - .standardHorizontalMargin(), - ) - } - item(key = "no_folders_spacer") { - Spacer(modifier = Modifier.height(height = 16.dp)) - } - } - - if (state.collectionItems.isNotEmpty()) { - item(key = "collection_header") { - BitwardenListHeaderText( - label = stringResource( - id = vfo1Foundation( - new = BitwardenString.shared_folders, - old = BitwardenString.collections, - ), - ), - supportingLabel = state.collectionItems.count().toString(), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .standardHorizontalMargin() - .padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(height = 8.dp)) - } - - itemsIndexed( - items = state.collectionItems, - key = { _, collection -> "collection_${collection.id}" }, - ) { index, collection -> - BitwardenGroupItem( - startIcon = IconData.Local( - iconRes = vfo1Foundation( - new = BitwardenDrawable.ic_shared_folder, - old = BitwardenDrawable.ic_collections, - ), - ), - label = collection.name, - supportingLabel = collection.itemCount.toString(), - onClick = { vaultHandlers.collectionClick(collection) }, - cardStyle = state - .collectionItems - .toListItemCardStyle(index = index, dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("CollectionFilter") - .standardHorizontalMargin(), - ) - } - item(key = "collections_spacer") { - Spacer(modifier = Modifier.height(height = 16.dp)) - } - } - - item(key = "hidden_items_header") { - BitwardenListHeaderText( - label = stringResource(id = BitwardenString.hidden_items), - supportingLabel = HIDDEN_TYPES_COUNT.toString(), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .standardHorizontalMargin() - .padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(height = 8.dp)) - } - - item(key = "archive_group") { - BitwardenGroupItem( - startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_archive), - endIcon = state.archiveEndIcon?.let { IconData.Local(iconRes = it) }, - label = stringResource(id = BitwardenString.archive_noun), - subLabel = state.archiveSubText?.invoke(), - supportingLabel = state.archivedItemsCount?.toString().orEmpty(), - onClick = vaultHandlers.archiveClick, - cardStyle = CardStyle.Top(dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag(tag = "ArchiveFilter") - .standardHorizontalMargin(), - ) - } - - item(key = "trash_group") { - BitwardenGroupItem( - startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_trash), - label = stringResource(id = BitwardenString.trash), - supportingLabel = state.trashItemsCount.toString(), - onClick = vaultHandlers.trashClick, - cardStyle = CardStyle.Bottom, - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag("TrashFilter") - .standardHorizontalMargin(), - ) - } + hiddenItemsSection( + state = state, + collapsedSections = collapsedSections, + vaultHandlers = vaultHandlers, + ) item(key = "bottom_padding") { Spacer(modifier = Modifier.height(height = 88.dp)) @@ -570,3 +197,460 @@ fun VaultContent( } } } + +/** + * Adds the verification codes section to the list. + */ +private fun LazyListScope.totpSection( + state: VaultState.ViewState.Content, + collapsedSections: ImmutableSet, + vaultHandlers: VaultHandlers, +) { + if (state.totpItemsCount <= 0) return + expandingSectionHeader( + section = VaultSection.TOTP, + labelRes = BitwardenString.totp, + supportingLabel = TOTP_TYPES_COUNT.toString(), + collapsedSections = collapsedSections, + onClick = vaultHandlers.sectionHeaderClick, + ) + if (VaultSection.TOTP in collapsedSections) return + item(key = "verification_codes_group") { + BitwardenGroupItem( + startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_clock), + label = stringResource(id = BitwardenString.verification_codes), + supportingLabel = state.totpItemsCount.toString(), + onClick = vaultHandlers.verificationCodesClick, + cardStyle = CardStyle.Full, + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("VerificationCodesFilter") + .standardHorizontalMargin(), + ) + } +} + +/** + * Adds the favorite items section to the list. + */ +private fun LazyListScope.favoritesSection( + state: VaultState.ViewState.Content, + collapsedSections: ImmutableSet, + vaultHandlers: VaultHandlers, + onVaultItemClick: (VaultState.ViewState.VaultItem) -> Unit, + onVaultItemOverflowOptionClick: ( + VaultState.ViewState.VaultItem, + ListingItemOverflowAction.VaultAction, + ) -> Unit, +) { + if (state.favoriteItems.isEmpty()) return + expandingSectionHeader( + section = VaultSection.FAVORITES, + labelRes = BitwardenString.favorites, + supportingLabel = state.favoriteItems.count().toString(), + collapsedSections = collapsedSections, + onClick = vaultHandlers.sectionHeaderClick, + ) + if (VaultSection.FAVORITES in collapsedSections) return + itemsIndexed( + items = state.favoriteItems, + key = { _, favorite -> "favorite_${favorite.id}" }, + ) { index, favoriteItem -> + VaultEntryListItem( + startIcon = favoriteItem.startIcon, + startIconTestTag = favoriteItem.startIconTestTag, + trailingLabelIcons = favoriteItem.extraIconList, + label = favoriteItem.name(), + supportingLabel = favoriteItem.supportingLabel?.invoke(), + onClick = { onVaultItemClick(favoriteItem) }, + overflowOptions = favoriteItem.overflowOptions, + onOverflowOptionClick = { action -> + onVaultItemOverflowOptionClick(favoriteItem, action) + }, + cardStyle = state + .favoriteItems + .toListItemCardStyle(index = index, dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("CipherCell") + .standardHorizontalMargin(), + ) + } +} + +/** + * Adds the item types section to the list. + */ +@Suppress("LongMethod") +private fun LazyListScope.typesSection( + state: VaultState.ViewState.Content, + collapsedSections: ImmutableSet, + vaultHandlers: VaultHandlers, +) { + expandingSectionHeader( + section = VaultSection.TYPES, + labelRes = BitwardenString.types, + supportingLabel = state.itemTypesCount.toString(), + collapsedSections = collapsedSections, + onClick = vaultHandlers.sectionHeaderClick, + ) + if (VaultSection.TYPES in collapsedSections) return + item(key = "logins_group") { + BitwardenGroupItem( + startIcon = IconData.Local( + iconRes = BitwardenDrawable.ic_globe, + testTag = "LoginCipherIcon", + ), + label = stringResource(id = BitwardenString.type_login), + supportingLabel = state.loginItemsCount.toString(), + onClick = vaultHandlers.loginGroupClick, + cardStyle = CardStyle.Top(dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("LoginFilter") + .standardHorizontalMargin(), + ) + } + + if (state.showCardGroup) { + item(key = "cards_group") { + BitwardenGroupItem( + startIcon = IconData.Local( + iconRes = BitwardenDrawable.ic_payment_card, + testTag = "CardCipherIcon", + ), + label = stringResource(id = BitwardenString.type_card), + supportingLabel = state.cardItemsCount.toString(), + onClick = vaultHandlers.cardGroupClick, + cardStyle = CardStyle.Middle(dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("CardFilter") + .standardHorizontalMargin(), + ) + } + } + + if (state.showBankAccountGroup) { + item(key = "bank_accounts_group") { + BitwardenGroupItem( + startIcon = IconData.Local( + iconRes = BitwardenDrawable.ic_payment_card, + testTag = "BankAccountCipherIcon", + ), + label = stringResource(id = BitwardenString.type_bank_account), + supportingLabel = state.bankAccountItemsCount.toString(), + onClick = vaultHandlers.bankAccountGroupClick, + cardStyle = CardStyle.Middle(dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("BankAccountFilter") + .standardHorizontalMargin(), + ) + } + } + + item(key = "identities_group") { + BitwardenGroupItem( + startIcon = IconData.Local( + iconRes = BitwardenDrawable.ic_id_card, + testTag = "IdentityCipherIcon", + ), + label = stringResource(id = BitwardenString.type_identity), + supportingLabel = state.identityItemsCount.toString(), + onClick = vaultHandlers.identityGroupClick, + cardStyle = CardStyle.Middle(dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("IdentityFilter") + .standardHorizontalMargin(), + ) + } + + if (state.showLicenseGroup) { + item(key = "licenses_group") { + BitwardenGroupItem( + startIcon = IconData.Local( + iconRes = BitwardenDrawable.ic_id_card, + testTag = "LicenseCipherIcon", + ), + label = stringResource(id = BitwardenString.type_license), + supportingLabel = state.licenseItemsCount.toString(), + onClick = vaultHandlers.licenseGroupClick, + cardStyle = CardStyle.Middle(dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("LicenseFilter") + .standardHorizontalMargin(), + ) + } + } + + if (state.showPassportGroup) { + item(key = "passports_group") { + BitwardenGroupItem( + startIcon = IconData.Local( + iconRes = BitwardenDrawable.ic_passport, + testTag = "PassportCipherIcon", + ), + label = stringResource(id = BitwardenString.type_passport), + supportingLabel = state.passportItemsCount.toString(), + onClick = vaultHandlers.passportGroupClick, + cardStyle = CardStyle.Middle(dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("PassportFilter") + .standardHorizontalMargin(), + ) + } + } + + item(key = "notes_group") { + BitwardenGroupItem( + startIcon = IconData.Local( + iconRes = BitwardenDrawable.ic_note, + testTag = "SecureNoteCipherIcon", + ), + label = stringResource(id = BitwardenString.type_secure_note), + supportingLabel = state.secureNoteItemsCount.toString(), + onClick = vaultHandlers.secureNoteGroupClick, + cardStyle = CardStyle.Middle(dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("SecureNoteFilter") + .standardHorizontalMargin(), + ) + } + + item(key = "ssh_keys_group") { + BitwardenGroupItem( + startIcon = IconData.Local( + iconRes = BitwardenDrawable.ic_ssh_key, + testTag = "SshKeyCipherIcon", + ), + label = stringResource(id = BitwardenString.type_ssh_key), + supportingLabel = state.sshKeyItemsCount.toString(), + onClick = vaultHandlers.sshKeyGroupClick, + cardStyle = CardStyle.Bottom, + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("SshKeyFilter") + .standardHorizontalMargin(), + ) + } +} + +/** + * Adds the folders section to the list. + */ +private fun LazyListScope.foldersSection( + state: VaultState.ViewState.Content, + collapsedSections: ImmutableSet, + vaultHandlers: VaultHandlers, + @StringRes labelRes: Int, +) { + if (state.folderItems.isEmpty()) return + expandingSectionHeader( + section = VaultSection.FOLDERS, + labelRes = labelRes, + supportingLabel = state.folderItems.count().toString(), + collapsedSections = collapsedSections, + onClick = vaultHandlers.sectionHeaderClick, + ) + if (VaultSection.FOLDERS in collapsedSections) return + itemsIndexed( + items = state.folderItems, + key = { _, folder -> "folder_${folder.id}" }, + ) { index, folder -> + BitwardenGroupItem( + startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_folder), + label = folder.name(), + supportingLabel = folder.itemCount.toString(), + onClick = { vaultHandlers.folderClick(folder) }, + cardStyle = state + .folderItems + .toListItemCardStyle(index = index, dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("FolderFilter") + .standardHorizontalMargin(), + ) + } +} + +/** + * Adds the items without a folder section to the list. + */ +private fun LazyListScope.noFolderSection( + state: VaultState.ViewState.Content, + collapsedSections: ImmutableSet, + vaultHandlers: VaultHandlers, + onVaultItemClick: (VaultState.ViewState.VaultItem) -> Unit, + onVaultItemOverflowOptionClick: ( + VaultState.ViewState.VaultItem, + ListingItemOverflowAction.VaultAction, + ) -> Unit, +) { + if (state.noFolderItems.isEmpty()) return + expandingSectionHeader( + section = VaultSection.NO_FOLDER, + labelRes = BitwardenString.folder_none, + supportingLabel = state.noFolderItems.count().toString(), + collapsedSections = collapsedSections, + onClick = vaultHandlers.sectionHeaderClick, + ) + if (VaultSection.NO_FOLDER in collapsedSections) return + itemsIndexed( + items = state.noFolderItems, + key = { _, noFolderItem -> "no_folder_${noFolderItem.id}" }, + ) { index, noFolderItem -> + VaultEntryListItem( + startIcon = noFolderItem.startIcon, + startIconTestTag = noFolderItem.startIconTestTag, + trailingLabelIcons = noFolderItem.extraIconList, + label = noFolderItem.name(), + supportingLabel = noFolderItem.supportingLabel?.invoke(), + onClick = { onVaultItemClick(noFolderItem) }, + overflowOptions = noFolderItem.overflowOptions, + onOverflowOptionClick = { action -> + onVaultItemOverflowOptionClick(noFolderItem, action) + }, + cardStyle = state + .noFolderItems + .toListItemCardStyle(index = index, dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("CipherCell") + .standardHorizontalMargin(), + ) + } +} + +/** + * Adds the collections section to the list. + */ +private fun LazyListScope.collectionsSection( + state: VaultState.ViewState.Content, + collapsedSections: ImmutableSet, + vaultHandlers: VaultHandlers, + @StringRes labelRes: Int, +) { + if (state.collectionItems.isEmpty()) return + expandingSectionHeader( + section = VaultSection.COLLECTIONS, + labelRes = labelRes, + supportingLabel = state.collectionItems.count().toString(), + collapsedSections = collapsedSections, + onClick = vaultHandlers.sectionHeaderClick, + ) + if (VaultSection.COLLECTIONS in collapsedSections) return + itemsIndexed( + items = state.collectionItems, + key = { _, collection -> "collection_${collection.id}" }, + ) { index, collection -> + BitwardenGroupItem( + startIcon = IconData.Local( + iconRes = vfo1Foundation( + new = BitwardenDrawable.ic_shared_folder, + old = BitwardenDrawable.ic_collections, + ), + ), + label = collection.name, + supportingLabel = collection.itemCount.toString(), + onClick = { vaultHandlers.collectionClick(collection) }, + cardStyle = state + .collectionItems + .toListItemCardStyle(index = index, dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("CollectionFilter") + .standardHorizontalMargin(), + ) + } +} + +/** + * Adds the hidden items section to the list. + */ +private fun LazyListScope.hiddenItemsSection( + state: VaultState.ViewState.Content, + collapsedSections: ImmutableSet, + vaultHandlers: VaultHandlers, +) { + expandingSectionHeader( + section = VaultSection.HIDDEN_ITEMS, + labelRes = BitwardenString.hidden_items, + supportingLabel = HIDDEN_TYPES_COUNT.toString(), + collapsedSections = collapsedSections, + onClick = vaultHandlers.sectionHeaderClick, + ) + if (VaultSection.HIDDEN_ITEMS in collapsedSections) return + item(key = "archive_group") { + BitwardenGroupItem( + startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_archive), + endIcon = state.archiveEndIcon?.let { IconData.Local(iconRes = it) }, + label = stringResource(id = BitwardenString.archive_noun), + subLabel = state.archiveSubText?.invoke(), + supportingLabel = state.archivedItemsCount?.toString().orEmpty(), + onClick = vaultHandlers.archiveClick, + cardStyle = CardStyle.Top(dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag(tag = "ArchiveFilter") + .standardHorizontalMargin(), + ) + } + + item(key = "trash_group") { + BitwardenGroupItem( + startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_trash), + label = stringResource(id = BitwardenString.trash), + supportingLabel = state.trashItemsCount.toString(), + onClick = vaultHandlers.trashClick, + cardStyle = CardStyle.Bottom, + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag("TrashFilter") + .standardHorizontalMargin(), + ) + } +} + +/** + * Adds an expandable header for the given vault [section] to the list. + */ +private fun LazyListScope.expandingSectionHeader( + section: VaultSection, + @StringRes labelRes: Int, + supportingLabel: String, + collapsedSections: ImmutableSet, + onClick: (VaultSection) -> Unit, +) { + item(key = "${section.name.lowercase()}_header") { + BitwardenExpandingListHeader( + label = stringResource(id = labelRes), + supportingLabel = supportingLabel, + isExpanded = section !in collapsedSections, + onClick = { onClick(section) }, + modifier = Modifier + .animateItem() + .fillMaxWidth() + .standardHorizontalMargin(), + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index 181e87890d..9dd993080a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -330,6 +330,7 @@ private fun VaultScreenScaffold( is VaultState.ViewState.Content -> VaultContent( state = viewState, actionCardState = state.actionCard, + collapsedSections = state.collapsedSections, vaultHandlers = vaultHandlers, modifier = Modifier.fillMaxSize(), ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 57faeff61a..f5c278cef7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -52,6 +52,7 @@ import com.x8bit.bitwarden.data.platform.manager.policy.UserNotificationPolicyMa import com.x8bit.bitwarden.data.platform.manager.policy.model.UserNotificationPolicyData import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -169,6 +170,7 @@ class VaultViewModel @Inject constructor( .getIntroducingArchiveActionCardDismissedFlow() .value, validTotpIds = persistentSetOf(), + collapsedSections = settingsRepository.collapsedVaultSections.toImmutableSet(), policyBanner = userNotificationPolicyManager.displayData?.let { PolicyBanner( organizationId = it.organizationId, @@ -279,6 +281,12 @@ class VaultViewModel @Inject constructor( .onEach(::sendAction) .launchIn(viewModelScope) + settingsRepository + .getCollapsedVaultSectionsFlow() + .map { VaultAction.Internal.CollapsedSectionsReceive(collapsedSections = it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + userNotificationPolicyManager .displayDataFlow .map { VaultAction.Internal.UserNotificationPolicyReceive(policyInfo = it) } @@ -404,9 +412,14 @@ class VaultViewModel @Inject constructor( VaultAction.UpgradeToPremiumClick -> handleUpgradeToPremiumClick() is VaultAction.DismissActionCardClick -> handleDismissActionCardClick(action) is VaultAction.ActionCardClick -> handleActionCardClick(action) + is VaultAction.SectionHeaderClick -> handleSectionHeaderClick(action) } } + private fun handleSectionHeaderClick(action: VaultAction.SectionHeaderClick) { + settingsRepository.toggleVaultSectionCollapsed(section = action.section) + } + private fun handleDismissFlightRecorderSnackbar() { settingsRepository.dismissFlightRecorderBanner() } @@ -1182,6 +1195,10 @@ class VaultViewModel @Inject constructor( handleIntroducingArchiveActionCardDismissedFlowReceive(action) } + is VaultAction.Internal.CollapsedSectionsReceive -> { + handleCollapsedSectionsReceive(action) + } + is VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive -> { handlePremiumUpgradeBannerEligibilityReceive(action) } @@ -1334,6 +1351,14 @@ class VaultViewModel @Inject constructor( } } + private fun handleCollapsedSectionsReceive( + action: VaultAction.Internal.CollapsedSectionsReceive, + ) { + mutableStateFlow.update { + it.copy(collapsedSections = action.collapsedSections.toImmutableSet()) + } + } + private fun handlePremiumUpgradeBannerEligibilityReceive( action: VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive, ) { @@ -1855,6 +1880,7 @@ data class VaultState( val isUpgradedToPremiumCardEligible: Boolean = false, val isAwaitingKdfSync: Boolean = false, val validTotpIds: ImmutableSet, + val collapsedSections: ImmutableSet = persistentSetOf(), val isNewItemTypesEnabled: Boolean = false, val isVfo1FoundationEnabled: Boolean = false, val policyBanner: PolicyBanner?, @@ -2779,6 +2805,13 @@ sealed class VaultAction { val actionCard: VaultState.ActionCardState, ) : VaultAction() + /** + * User clicked the header of the given [section] to expand or collapse it. + */ + data class SectionHeaderClick( + val section: VaultSection, + ) : VaultAction() + /** * User clicked the primary button on an action card. */ @@ -2930,6 +2963,13 @@ sealed class VaultAction { val isDismissed: Boolean, ) : Internal() + /** + * Indicates that the set of collapsed vault sections has been updated. + */ + data class CollapsedSectionsReceive( + val collapsedSections: Set, + ) : Internal() + /** * Indicates that the Premium upgrade banner eligibility has been updated. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt index 2bb0288a29..14fa2a661b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault.handlers import com.bitwarden.ui.platform.components.account.model.AccountSummary +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.VaultAction @@ -54,6 +55,7 @@ data class VaultHandlers( val upgradeToPremiumClick: () -> Unit, val dismissActionCardClick: (VaultState.ActionCardState) -> Unit, val actionCardClick: (VaultState.ActionCardState) -> Unit, + val sectionHeaderClick: (VaultSection) -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -157,6 +159,9 @@ data class VaultHandlers( viewModel.trySendAction(VaultAction.DismissActionCardClick(it)) }, actionCardClick = { viewModel.trySendAction(VaultAction.ActionCardClick(it)) }, + sectionHeaderClick = { + viewModel.trySendAction(VaultAction.SectionHeaderClick(section = it)) + }, ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt index 903f84f925..53a44d9b24 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -10,6 +10,7 @@ import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -175,6 +176,10 @@ class SettingsDiskSourceTest { lastSyncTime = Instant.parse("2023-10-27T12:00:00Z"), ) settingsDiskSource.storeClearClipboardFrequencySeconds(userId = userId, frequency = 5) + settingsDiskSource.storeCollapsedVaultSections( + userId = userId, + collapsedVaultSections = setOf(VaultSection.TYPES), + ) val systemBioIntegrityState = "system_biometrics_integrity_state" settingsDiskSource.storeAccountBiometricIntegrityValidity( userId = userId, @@ -226,6 +231,7 @@ class SettingsDiskSourceTest { ), ) assertNull(settingsDiskSource.getVaultPolicyBannerDismissedDate(userId = userId)) + assertNull(settingsDiskSource.getCollapsedVaultSections(userId = userId)) } @Suppress("MaxLineLength") @@ -917,6 +923,70 @@ class SettingsDiskSourceTest { } } + @Test + fun `getCollapsedVaultSections when values are present should pull from SharedPreferences`() { + val collapsedVaultSectionsBaseKey = "bwPreferencesStorage:collapsedVaultSections" + val mockUserId = "mockUserId" + val collapsedVaultSectionsKey = "${collapsedVaultSectionsBaseKey}_$mockUserId" + assertNull(settingsDiskSource.getCollapsedVaultSections(userId = mockUserId)) + fakeSharedPreferences.edit { + putString(collapsedVaultSectionsKey, """["types","hiddenItems"]""") + } + assertEquals( + setOf(VaultSection.TYPES, VaultSection.HIDDEN_ITEMS), + settingsDiskSource.getCollapsedVaultSections(userId = mockUserId), + ) + } + + @Test + fun `getCollapsedVaultSections should skip unknown stored values`() { + val collapsedVaultSectionsBaseKey = "bwPreferencesStorage:collapsedVaultSections" + val mockUserId = "mockUserId" + val collapsedVaultSectionsKey = "${collapsedVaultSectionsBaseKey}_$mockUserId" + fakeSharedPreferences.edit { + putString(collapsedVaultSectionsKey, """["types","unknownSection"]""") + } + assertEquals( + setOf(VaultSection.TYPES), + settingsDiskSource.getCollapsedVaultSections(userId = mockUserId), + ) + } + + @Test + fun `storeCollapsedVaultSections should update SharedPreferences`() { + val collapsedVaultSectionsBaseKey = "bwPreferencesStorage:collapsedVaultSections" + val mockUserId = "mockUserId" + val collapsedVaultSectionsKey = "${collapsedVaultSectionsBaseKey}_$mockUserId" + settingsDiskSource.storeCollapsedVaultSections( + userId = mockUserId, + collapsedVaultSections = setOf(VaultSection.NO_FOLDER), + ) + assertEquals( + """["noFolder"]""", + fakeSharedPreferences.getString(collapsedVaultSectionsKey, null), + ) + } + + @Test + fun `getCollapsedVaultSectionsFlow should react to changes in storeCollapsedVaultSections`() = + runTest { + val mockUserId = "mockUserId" + settingsDiskSource + .getCollapsedVaultSectionsFlow(userId = mockUserId) + .test { + // The initial values of the Flow and the property are in sync + assertNull(settingsDiskSource.getCollapsedVaultSections(userId = mockUserId)) + assertNull(awaitItem()) + + // Updating the disk source updates shared preferences + settingsDiskSource.storeCollapsedVaultSections( + userId = mockUserId, + collapsedVaultSections = setOf(VaultSection.FAVORITES), + ) + assertEquals(setOf(VaultSection.FAVORITES), awaitItem()) + } + } + @Suppress("MaxLineLength") @Test fun `getPremiumUpgradeBannerDismissed when values are present should pull from SharedPreferences`() { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt index 6f816d333c..fe9e9527a6 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt @@ -9,6 +9,7 @@ import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -20,6 +21,7 @@ import java.time.Instant /** * Fake, memory-based implementation of [SettingsDiskSource]. */ +@Suppress("LargeClass") class FakeSettingsDiskSource( flightRecorderDiskSource: FakeFlightRecorderDiskSource = FakeFlightRecorderDiskSource(), ) : SettingsDiskSource, @@ -70,6 +72,7 @@ class FakeSettingsDiskSource( private val storedDisableAutofillSavePrompt = mutableMapOf() private val storedPullToRefreshEnabled = mutableMapOf() private var storedIntroducingArchiveActionCardDismissed = mutableMapOf() + private var storedCollapsedVaultSections = mutableMapOf?>() private var storedPremiumUpgradeBannerDismissed = mutableMapOf() private val storedVaultPolicyBannerDismissedDate = mutableMapOf() private val storedUpgradedToPremiumCardConsumed = mutableMapOf() @@ -124,6 +127,9 @@ class FakeSettingsDiskSource( private val mutableIntroducingArchiveActionCardDismissedFlow = mutableMapOf>() + private val mutableCollapsedVaultSectionsFlow = + mutableMapOf?>>() + private val mutablePremiumUpgradeBannerDismissedFlow = mutableMapOf>() @@ -274,6 +280,7 @@ class FakeSettingsDiskSource( storedBlockedAutofillUris.remove(userId) storedClearClipboardFrequency.remove(userId) storedVaultPolicyBannerDismissedDate.remove(userId) + storedCollapsedVaultSections.remove(userId) mutableVaultTimeoutActionsFlowMap.remove(userId) mutableVaultTimeoutInMinutesFlowMap.remove(userId) @@ -382,6 +389,21 @@ class FakeSettingsDiskSource( getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId).tryEmit(isDismissed) } + override fun getCollapsedVaultSections(userId: String): Set? = + storedCollapsedVaultSections[userId] + + override fun storeCollapsedVaultSections( + userId: String, + collapsedVaultSections: Set?, + ) { + storedCollapsedVaultSections[userId] = collapsedVaultSections + getMutableCollapsedVaultSectionsFlow(userId = userId).tryEmit(collapsedVaultSections) + } + + override fun getCollapsedVaultSectionsFlow(userId: String): Flow?> = + getMutableCollapsedVaultSectionsFlow(userId = userId) + .onSubscription { emit(getCollapsedVaultSections(userId = userId)) } + override fun getPremiumUpgradeBannerDismissed(userId: String): Boolean? = storedPremiumUpgradeBannerDismissed[userId] @@ -616,6 +638,13 @@ class FakeSettingsDiskSource( assertEquals(expected, storedIntroducingArchiveActionCardDismissed[userId]) } + /** + * Asserts that the stored collapsed vault sections match the [expected] ones. + */ + fun assertCollapsedVaultSections(userId: String, expected: Set?) { + assertEquals(expected, storedCollapsedVaultSections[userId]) + } + /** * Asserts that the stored Premium upgrade banner dismissed matches the [expected] one. */ @@ -722,6 +751,13 @@ class FakeSettingsDiskSource( bufferedMutableSharedFlow(replay = 1) } + private fun getMutableCollapsedVaultSectionsFlow( + userId: String, + ): MutableSharedFlow?> = + mutableCollapsedVaultSectionsFlow.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + private fun getMutablePremiumUpgradeBannerDismissedFlow( userId: String, ): MutableSharedFlow = diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index ca79ebddf4..3506308e0c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -34,6 +34,7 @@ import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource @@ -948,6 +949,69 @@ class SettingsRepositoryTest { } } + @Test + fun `toggleVaultSectionCollapsed should add or remove the section in SettingsDiskSource`() { + fakeAuthDiskSource.userState = MOCK_USER_STATE + settingsRepository.toggleVaultSectionCollapsed(section = VaultSection.TYPES) + settingsRepository.toggleVaultSectionCollapsed(section = VaultSection.FOLDERS) + fakeSettingsDiskSource.assertCollapsedVaultSections( + userId = USER_ID, + expected = setOf(VaultSection.TYPES, VaultSection.FOLDERS), + ) + + settingsRepository.toggleVaultSectionCollapsed(section = VaultSection.TYPES) + fakeSettingsDiskSource.assertCollapsedVaultSections( + userId = USER_ID, + expected = setOf(VaultSection.FOLDERS), + ) + } + + @Test + fun `collapsedVaultSections should pull from SettingsDiskSource`() { + fakeAuthDiskSource.userState = MOCK_USER_STATE + assertEquals(emptySet(), settingsRepository.collapsedVaultSections) + fakeSettingsDiskSource.storeCollapsedVaultSections( + userId = USER_ID, + collapsedVaultSections = setOf(VaultSection.TOTP), + ) + assertEquals(setOf(VaultSection.TOTP), settingsRepository.collapsedVaultSections) + } + + @Test + fun `collapsed vault sections should be empty and unchanged when there is no active user`() = + runTest { + fakeAuthDiskSource.userState = null + settingsRepository.toggleVaultSectionCollapsed(section = VaultSection.TYPES) + + fakeSettingsDiskSource.assertCollapsedVaultSections(userId = USER_ID, expected = null) + assertEquals(emptySet(), settingsRepository.collapsedVaultSections) + settingsRepository.getCollapsedVaultSectionsFlow().test { + assertEquals(emptySet(), awaitItem()) + awaitComplete() + } + } + + @Test + fun `getCollapsedVaultSectionsFlow should react to changes in SettingsDiskSource`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + settingsRepository + .getCollapsedVaultSectionsFlow() + .test { + assertEquals(emptySet(), awaitItem()) + fakeSettingsDiskSource.storeCollapsedVaultSections( + userId = USER_ID, + collapsedVaultSections = setOf(VaultSection.TOTP), + ) + assertEquals(setOf(VaultSection.TOTP), awaitItem()) + fakeSettingsDiskSource.storeCollapsedVaultSections( + userId = USER_ID, + collapsedVaultSections = null, + ) + assertEquals(emptySet(), awaitItem()) + } + } + @Test fun `dismissPremiumUpgradeBanner should properly update SettingsDiskSource`() { fakeAuthDiskSource.userState = MOCK_USER_STATE diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 78891ef55c..0d69680d8d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -49,6 +49,7 @@ import com.bitwarden.ui.util.performRemoveAccountClick import com.bitwarden.ui.util.performYesDialogButtonClick import com.bitwarden.vault.CipherType import com.x8bit.bitwarden.data.billing.model.PremiumCard +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState @@ -68,6 +69,7 @@ import io.mockk.runs import io.mockk.verify import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.runTest @@ -1230,7 +1232,7 @@ class VaultScreenTest : BitwardenComposeTest() { } composeTestRule - .onNodeWithText("TOTP (1)") + .onNodeWithText("TOTP") .performScrollTo() .assertIsDisplayed() @@ -1248,7 +1250,7 @@ class VaultScreenTest : BitwardenComposeTest() { } composeTestRule - .onNodeWithText("TOTP (1)") + .onNodeWithText("TOTP") .assertIsNotDisplayed() composeTestRule @@ -1274,6 +1276,87 @@ class VaultScreenTest : BitwardenComposeTest() { verify { viewModel.trySendAction(VaultAction.VerificationCodesClick) } } + @Test + fun `clicking a section header should send SectionHeaderClick with the correct section`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE) } + + composeTestRule + .onNodeWithText("TYPES") + .performScrollTo() + .performClick() + + verify { viewModel.trySendAction(VaultAction.SectionHeaderClick(VaultSection.TYPES)) } + } + + @Test + fun `section content should be displayed or hidden according to collapsed sections`() { + val vaultItem = VaultState.ViewState.VaultItem.Login( + id = "12345", + name = "Favorite Item".asText(), + username = "Bitwarden".asText(), + overflowOptions = persistentListOf(), + shouldShowMasterPasswordReprompt = false, + hasDecryptionError = false, + ) + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + totpItemsCount = 1, + favoriteItems = listOf(vaultItem), + folderItems = listOf( + VaultState.ViewState.FolderItem( + id = "12345", + name = "Test Folder".asText(), + itemCount = 1, + ), + ), + noFolderItems = listOf( + vaultItem.copy(id = "67890", name = "No Folder Item".asText()), + ), + collectionItems = listOf( + VaultState.ViewState.CollectionItem( + id = "12345", + name = "Test Collection", + itemCount = 1, + ), + ), + ), + ) + } + val sectionContentLabels = listOf( + "Verification codes", + "Favorite Item", + "Login", + "Test Folder", + "No Folder Item", + "Test Collection", + "Archive", + "Trash", + ) + sectionContentLabels.forEach { label -> + composeTestRule.onNodeWithTextAfterScroll(text = label).assertIsDisplayed() + } + + mutableStateFlow.update { + it.copy(collapsedSections = VaultSection.entries.toImmutableSet()) + } + + listOf( + "TOTP", + "FAVORITES", + "TYPES", + "MY FOLDERS", + "NO FOLDER", + "SHARED FOLDERS", + "HIDDEN ITEMS", + ).forEach { header -> + composeTestRule.onNodeWithTextAfterScroll(text = header).assertIsDisplayed() + } + sectionContentLabels.forEach { label -> + composeTestRule.onNodeWithText(text = label).assertDoesNotExist() + } + } + @Test fun `clicking a favorite item should send VaultItemClick with the correct item`() { val itemText = "Test Item" @@ -1296,7 +1379,7 @@ class VaultScreenTest : BitwardenComposeTest() { // Header composeTestRule - .onNodeWithText("FAVORITES (1)") + .onNodeWithText("FAVORITES") .performScrollTo() .assertIsDisplayed() // Item @@ -1857,7 +1940,7 @@ class VaultScreenTest : BitwardenComposeTest() { @Test fun `collection data should update according to the state`() { - val collectionsHeader = "SHARED FOLDERS (1)" + val collectionsHeader = "SHARED FOLDERS" val collectionName = "Test Collection" val collectionCount = 3 val collectionItem = VaultState.ViewState.CollectionItem( @@ -1879,7 +1962,7 @@ class VaultScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithTextAfterScroll(collectionsHeader, substring = true) - .assertTextEquals(collectionsHeader) + .assertTextEquals(collectionsHeader, "1") .assertIsDisplayed() composeTestRule .onNodeWithTextAfterScroll(collectionName) @@ -1971,7 +2054,10 @@ class VaultScreenTest : BitwardenComposeTest() { ) } - composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText)) + // Scroll past the item so its overflow button is not covered by the FAB. + composeTestRule + .onNode(hasScrollToNodeAction()) + .performScrollToNode(hasText("Trash")) composeTestRule .onNodeWithText(text = itemText) .onChildren() @@ -2042,7 +2128,10 @@ class VaultScreenTest : BitwardenComposeTest() { ) } - composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText)) + // Scroll past the item so its overflow button is not covered by the FAB. + composeTestRule + .onNode(hasScrollToNodeAction()) + .performScrollToNode(hasText("Trash")) composeTestRule .onNodeWithText(text = itemText) .onChildren() @@ -2392,7 +2481,7 @@ class VaultScreenTest : BitwardenComposeTest() { } // Header composeTestRule - .onNodeWithTextAfterScroll(text = "HIDDEN ITEMS (2)") + .onNodeWithTextAfterScroll(text = "HIDDEN ITEMS") .assertIsDisplayed() // Item composeTestRule @@ -2406,7 +2495,7 @@ class VaultScreenTest : BitwardenComposeTest() { // Header composeTestRule - .onNodeWithTextAfterScroll(text = "HIDDEN ITEMS (2)") + .onNodeWithTextAfterScroll(text = "HIDDEN ITEMS") .assertIsDisplayed() // Item composeTestRule @@ -2436,7 +2525,7 @@ class VaultScreenTest : BitwardenComposeTest() { } // Header composeTestRule - .onNodeWithTextAfterScroll(text = "HIDDEN ITEMS (2)") + .onNodeWithTextAfterScroll(text = "HIDDEN ITEMS") .assertIsDisplayed() // Item composeTestRule @@ -2454,7 +2543,7 @@ class VaultScreenTest : BitwardenComposeTest() { // Header composeTestRule - .onNodeWithTextAfterScroll(text = "HIDDEN ITEMS (2)") + .onNodeWithTextAfterScroll(text = "HIDDEN ITEMS") .assertIsDisplayed() // Item composeTestRule diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index ac1724e9ec..eb8ef2512c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -49,6 +49,7 @@ import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManage import com.x8bit.bitwarden.data.platform.manager.policy.UserNotificationPolicyManager import com.x8bit.bitwarden.data.platform.manager.policy.model.UserNotificationPolicyData import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.platform.repository.model.VaultSection import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockBankAccountView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCardListView @@ -179,6 +180,7 @@ class VaultViewModelTest : BaseViewModelTest() { private var mutableFlightRecorderDataFlow = MutableStateFlow(FlightRecorderDataSet(data = emptySet())) private var mutableIntroducingArchiveActionCardDismissedFlow = MutableStateFlow(false) + private val mutableCollapsedVaultSectionsFlow = MutableStateFlow>(emptySet()) private val settingsRepository: SettingsRepository = mockk { every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshEnabledFlow every { isIconLoadingDisabledFlow } returns mutableIsIconLoadingDisabledFlow @@ -191,6 +193,9 @@ class VaultViewModelTest : BaseViewModelTest() { every { getIntroducingArchiveActionCardDismissedFlow() } returns mutableIntroducingArchiveActionCardDismissedFlow + every { collapsedVaultSections } returns emptySet() + every { getCollapsedVaultSectionsFlow() } returns mutableCollapsedVaultSectionsFlow + every { toggleVaultSectionCollapsed(section = any()) } just runs } private val vaultRepository: VaultRepository = mockk { @@ -378,6 +383,33 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `CollapsedVaultSectionsFlow updates should update the state accordingly`() = runTest { + val viewModel = createViewModel() + + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + mutableCollapsedVaultSectionsFlow.value = setOf(VaultSection.TYPES) + assertEquals( + DEFAULT_STATE.copy(collapsedSections = persistentSetOf(VaultSection.TYPES)), + awaitItem(), + ) + mutableCollapsedVaultSectionsFlow.value = emptySet() + assertEquals(DEFAULT_STATE, awaitItem()) + } + } + + @Test + fun `SectionHeaderClick should toggle the section collapsed state`() { + val viewModel = createViewModel() + + viewModel.trySendAction(VaultAction.SectionHeaderClick(section = VaultSection.FAVORITES)) + + verify(exactly = 1) { + settingsRepository.toggleVaultSectionCollapsed(section = VaultSection.FAVORITES) + } + } + @Suppress("MaxLineLength") @Test fun `ActionCardClick with IntroducingArchive should call dismissIntroducingArchiveActionCard and emit NavigateTo`() = diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt index 8742b28d6b..0623ad15c9 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt @@ -58,7 +58,7 @@ import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.bitwarden.ui.platform.components.fab.BitwardenExpandableFloatingActionButton import com.bitwarden.ui.platform.components.fab.model.ExpandableFabIcon import com.bitwarden.ui.platform.components.fab.model.ExpandableFabOption -import com.bitwarden.ui.platform.components.header.BitwardenExpandingHeader +import com.bitwarden.ui.platform.components.header.BitwardenExpandingListHeader import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.bitwarden.ui.platform.components.icon.model.IconData import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold @@ -360,14 +360,11 @@ private fun ItemListingContent( if (state.shouldShowLocalHeader) { item(key = "local_items_header") { - BitwardenExpandingHeader( - collapsedText = stringResource( - id = BitwardenString.local_codes, - state.itemList.size, - ), + BitwardenExpandingListHeader( + label = stringResource(id = BitwardenString.local_codes_header), + supportingLabel = state.itemList.size.toString(), isExpanded = isLocalHeaderExpanded, onClick = { isLocalHeaderExpanded = !isLocalHeaderExpanded }, - capitalizeText = true, modifier = Modifier .fillMaxWidth() .standardHorizontalMargin() @@ -402,11 +399,11 @@ private fun ItemListingContent( is SharedCodesDisplayState.Codes -> { state.sharedItems.sections.forEachIndexed { _, section -> item(key = "sharedSection_${section.id}") { - BitwardenExpandingHeader( - collapsedText = section.label(), + BitwardenExpandingListHeader( + label = section.label(), + supportingLabel = section.codes.size.toString(), isExpanded = section.isExpanded, onClick = { onSectionExpandedClick(section) }, - capitalizeText = true, modifier = Modifier .fillMaxWidth() .standardHorizontalMargin() diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchContent.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchContent.kt index b130cbd080..9f32d02419 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchContent.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchContent.kt @@ -93,6 +93,7 @@ private fun LazyListScope.sharedCodes( item { BitwardenListHeaderText( label = section.label(), + supportingLabel = section.codes.size.toString(), modifier = Modifier .fillMaxWidth() .standardHorizontalMargin() diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateExtensions.kt index 02ace24942..fcfc3d9f67 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateExtensions.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateExtensions.kt @@ -42,7 +42,6 @@ fun SharedVerificationCodesState.Success.toSharedCodesDisplayState( label = BitwardenString.shared_accounts_header.asText( it.key.email, it.key.environmentLabel, - it.value.size, ), codes = it.value.sortAlphabetically().toImmutableList(), isExpanded = currentSections diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt index 6c55828dd9..3b178003fb 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt @@ -442,7 +442,7 @@ class ItemListingScreenTest : AuthenticatorComposeTest() { ) composeTestRule - .onNodeWithText(text = "LOCAL CODES (1)") + .onNodeWithText(text = "LOCAL CODES") .assertIsDisplayed() composeTestRule @@ -528,7 +528,7 @@ class ItemListingScreenTest : AuthenticatorComposeTest() { .assertIsDisplayed() composeTestRule - .onNodeWithText(text = "LOCAL CODES (1)") + .onNodeWithText(text = "LOCAL CODES") .performClick() composeTestRule diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt index 5dc21be51b..cb81974e04 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/search/ItemSearchViewModelTest.kt @@ -401,7 +401,6 @@ private val SHARED_DISPLAY_ITEMS = SharedCodesDisplayState.Codes( label = BitwardenString.shared_accounts_header.asText( "mockEmail-2", "mockkEnvironmentLabel-2", - 1, ), codes = persistentListOf( VerificationCodeDisplayItem( diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateTest.kt index e60f2df581..de9597871a 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/SharedVerificationCodesStateTest.kt @@ -71,7 +71,6 @@ class SharedVerificationCodesStateTest { label = BitwardenString.shared_accounts_header.asText( "Jane@test.com", "bitwarden.eu", - 1, ), codes = persistentListOf( VerificationCodeDisplayItem( @@ -95,7 +94,6 @@ class SharedVerificationCodesStateTest { label = BitwardenString.shared_accounts_header.asText( "John@test.com", "bitwarden.com", - 1, ), codes = persistentListOf( VerificationCodeDisplayItem( @@ -170,7 +168,6 @@ class SharedVerificationCodesStateTest { label = BitwardenString.shared_accounts_header.asText( "Jane@test.com", "bitwarden.eu", - 1, ), codes = persistentListOf( VerificationCodeDisplayItem( @@ -194,7 +191,6 @@ class SharedVerificationCodesStateTest { label = BitwardenString.shared_accounts_header.asText( "John@test.com", "bitwarden.com", - 1, ), codes = persistentListOf( VerificationCodeDisplayItem( diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/model/util/SharedCodesAccountSectionExtensionsTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/model/util/SharedCodesAccountSectionExtensionsTest.kt index 4ace35056d..385fc5b8e3 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/model/util/SharedCodesAccountSectionExtensionsTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/model/util/SharedCodesAccountSectionExtensionsTest.kt @@ -16,7 +16,6 @@ class SharedCodesAccountSectionExtensionsTest { label = BitwardenString.shared_accounts_header.asText( "John@test.com", "bitwarden.com", - 1, ), codes = persistentListOf(), isExpanded = true, @@ -27,7 +26,6 @@ class SharedCodesAccountSectionExtensionsTest { label = BitwardenString.shared_accounts_header.asText( "Jane@test.com", "bitwarden.eu", - 1, ), codes = persistentListOf(), isExpanded = true, @@ -40,7 +38,6 @@ class SharedCodesAccountSectionExtensionsTest { label = BitwardenString.shared_accounts_header.asText( "Jane@test.com", "bitwarden.eu", - 1, ), codes = persistentListOf(), isExpanded = true, @@ -51,7 +48,6 @@ class SharedCodesAccountSectionExtensionsTest { label = BitwardenString.shared_accounts_header.asText( "John@test.com", "bitwarden.com", - 1, ), codes = persistentListOf(), isExpanded = true, diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/BitwardenExpandingHeader.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/BitwardenExpandingHeader.kt index 4bceceeaea..3be98df0e6 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/BitwardenExpandingHeader.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/BitwardenExpandingHeader.kt @@ -2,33 +2,17 @@ package com.bitwarden.ui.platform.components.header import androidx.compose.animation.Crossfade import androidx.compose.animation.core.AnimationConstants -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.toUpperCase import androidx.compose.ui.unit.dp -import com.bitwarden.ui.platform.base.util.interactiveBorder -import com.bitwarden.ui.platform.components.util.rememberVectorPainter -import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme @@ -42,7 +26,6 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme * @param interactionSource A [MutableInteractionSource] for observing and emitting interactions * for this component. */ -@Suppress("LongMethod") @Composable fun BitwardenExpandingHeader( isExpanded: Boolean, @@ -54,25 +37,16 @@ fun BitwardenExpandingHeader( showExpansionIndicator: Boolean = true, shape: Shape = BitwardenTheme.shapes.content, insets: PaddingValues = PaddingValues(top = 16.dp, bottom = 8.dp), - capitalizeText: Boolean = false, ) { - Row( - modifier = modifier - .clip(shape = shape) - .interactiveBorder(interactionSource = interactionSource, shape = shape) - .clickable( - onClickLabel = stringResource( - id = if (isExpanded) BitwardenString.collapse else BitwardenString.expand, - ), - onClick = onClick, - interactionSource = interactionSource, - role = Role.Button, - ) - .minimumInteractiveComponentSize() - .padding(horizontal = 16.dp) - .padding(paddingValues = insets) - .semantics(mergeDescendants = true) { heading() }, - verticalAlignment = Alignment.CenterVertically, + ExpandingHeaderRow( + isExpanded = isExpanded, + onClick = onClick, + shape = shape, + insets = insets, + showExpansionIndicator = showExpansionIndicator, + expansionIndicatorColor = BitwardenTheme.colorScheme.icon.secondary, + interactionSource = interactionSource, + modifier = modifier, ) { Crossfade( targetState = isExpanded, @@ -90,39 +64,19 @@ fun BitwardenExpandingHeader( ) { expanded -> if (expanded) { Text( - text = if (capitalizeText) { - expandedText.toUpperCase(locale = LocalLocale.current) - } else { - expandedText - }, + text = expandedText, color = BitwardenTheme.colorScheme.text.interaction, style = BitwardenTheme.typography.labelLarge, modifier = Modifier.padding(end = 8.dp), ) } else { Text( - text = if (capitalizeText) { - collapsedText.toUpperCase(locale = LocalLocale.current) - } else { - collapsedText - }, + text = collapsedText, color = BitwardenTheme.colorScheme.text.interaction, style = BitwardenTheme.typography.labelLarge, modifier = Modifier.padding(end = 8.dp), ) } } - if (showExpansionIndicator) { - val iconRotationDegrees = animateFloatAsState( - targetValue = if (isExpanded) 0f else 180f, - label = "expanderIconRotationAnimation", - ) - Icon( - painter = rememberVectorPainter(id = BitwardenDrawable.ic_chevron_up_small), - contentDescription = null, - tint = BitwardenTheme.colorScheme.icon.secondary, - modifier = Modifier.rotate(degrees = iconRotationDegrees.value), - ) - } } } diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/BitwardenExpandingListHeader.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/BitwardenExpandingListHeader.kt new file mode 100644 index 0000000000..0154cb369f --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/BitwardenExpandingListHeader.kt @@ -0,0 +1,96 @@ +package com.bitwarden.ui.platform.components.header + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalLocale +import androidx.compose.ui.text.toUpperCase +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * A Bitwarden-styled list section header that can be clicked to expand or collapse the content + * below it. + * + * @param label The text content for the header, displayed in uppercase. + * @param isExpanded Whether the section content is currently expanded. + * @param onClick Invoked when the header is clicked. + * @param modifier The [Modifier] to be applied to the header. + * @param supportingLabel The optional text displayed at the end of the header, before the + * expansion indicator. + * @param shape The [Shape] used to clip the header and draw the interactive border. + * @param interactionSource A [MutableInteractionSource] for observing and emitting interactions + * for this component. + */ +@Composable +fun BitwardenExpandingListHeader( + label: String, + isExpanded: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + supportingLabel: String? = null, + shape: Shape = BitwardenTheme.shapes.content, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + ExpandingHeaderRow( + isExpanded = isExpanded, + onClick = onClick, + shape = shape, + insets = PaddingValues(), + showExpansionIndicator = true, + expansionIndicatorColor = BitwardenTheme.colorScheme.text.secondary, + interactionSource = interactionSource, + modifier = modifier, + ) { + Text( + text = label.toUpperCase(locale = LocalLocale.current), + style = BitwardenTheme.typography.eyebrowMedium, + color = BitwardenTheme.colorScheme.text.secondary, + modifier = Modifier.weight(weight = 1f), + ) + supportingLabel?.let { + Spacer(modifier = Modifier.width(width = 8.dp)) + Text( + text = it, + style = BitwardenTheme.typography.labelMedium, + color = BitwardenTheme.colorScheme.text.secondary, + ) + } + Spacer(modifier = Modifier.width(width = 8.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenExpandingListHeader_preview() { + BitwardenTheme { + Column { + var isExpanded by remember { mutableStateOf(value = true) } + BitwardenExpandingListHeader( + label = "Item types", + supportingLabel = "8", + isExpanded = isExpanded, + onClick = { isExpanded = !isExpanded }, + modifier = Modifier.fillMaxWidth(), + ) + BitwardenExpandingListHeader( + label = "No folder", + isExpanded = false, + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/ExpandingHeaderRow.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/ExpandingHeaderRow.kt new file mode 100644 index 0000000000..b7c502c1bb --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/header/ExpandingHeaderRow.kt @@ -0,0 +1,90 @@ +package com.bitwarden.ui.platform.components.header + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.bitwarden.ui.platform.base.util.interactiveBorder +import com.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.ui.platform.resource.BitwardenDrawable +import com.bitwarden.ui.platform.resource.BitwardenString + +/** + * The clickable row shared by the expanding header components. It handles the interaction, + * accessibility semantics, and the animated expansion indicator, while the [content] provides + * the header text. + * + * @param isExpanded Whether the content controlled by the header is expanded. + * @param onClick Invoked when the header is clicked. + * @param shape The [Shape] used to clip the header and draw the interactive border. + * @param insets Additional padding applied inside the header, after the horizontal padding. + * @param showExpansionIndicator Whether to show an indicator to expand or collapse the content. + * @param expansionIndicatorColor The tint applied to the expansion indicator. + * @param interactionSource A [MutableInteractionSource] for observing and emitting interactions + * for this component. + * @param modifier The [Modifier] to be applied to the header. + * @param content The text content displayed before the expansion indicator. + */ +@Composable +internal fun ExpandingHeaderRow( + isExpanded: Boolean, + onClick: () -> Unit, + shape: Shape, + insets: PaddingValues, + showExpansionIndicator: Boolean, + expansionIndicatorColor: Color, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = modifier + .clip(shape = shape) + .interactiveBorder(interactionSource = interactionSource, shape = shape) + .clickable( + onClickLabel = stringResource( + id = if (isExpanded) BitwardenString.collapse else BitwardenString.expand, + ), + onClick = onClick, + interactionSource = interactionSource, + role = Role.Button, + ) + .minimumInteractiveComponentSize() + .padding(horizontal = 16.dp) + .padding(paddingValues = insets) + .semantics(mergeDescendants = true) { heading() }, + verticalAlignment = Alignment.CenterVertically, + ) { + content() + if (showExpansionIndicator) { + val iconRotationDegrees by animateFloatAsState( + targetValue = if (isExpanded) 0f else 180f, + label = "expanderIconRotationAnimation", + ) + Icon( + painter = rememberVectorPainter(id = BitwardenDrawable.ic_chevron_up_small), + contentDescription = null, + tint = expansionIndicatorColor, + modifier = Modifier.rotate(degrees = iconRotationDegrees), + ) + } + } +} diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index b7cf4f2ed5..28eb6fee17 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1143,6 +1143,7 @@ Do you want to switch to this account? Save option as default Account synced from Bitwarden app Local codes (%1$d) + Local codes Required Information Missing Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support File Could Not Be Processed diff --git a/ui/src/main/res/values/strings_non_localized.xml b/ui/src/main/res/values/strings_non_localized.xml index ae048c2fa5..20b1a29d6d 100644 --- a/ui/src/main/res/values/strings_non_localized.xml +++ b/ui/src/main/res/values/strings_non_localized.xml @@ -15,7 +15,7 @@ Fastmail ForwardEmail %s… - %1$s | %2$s (%3$d) + %1$s | %2$s Feature Flags