[PM-35396] feat: Add expand/collapse to vault section headers (#7504)

This commit is contained in:
André Bispo
2026-10-26 21:18:28 +00:00
committed by GitHub
parent ca18e2e925
commit 924c851b57
26 changed files with 1240 additions and 540 deletions
@@ -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<Boolean?>
/**
* 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<VaultSection>?
/**
* Stores the given set of [collapsedVaultSections] for the given [userId].
*/
fun storeCollapsedVaultSections(
userId: String,
collapsedVaultSections: Set<VaultSection>?,
)
/**
* Emits updates that track [getCollapsedVaultSections] for the given [userId].
*/
fun getCollapsedVaultSectionsFlow(userId: String): Flow<Set<VaultSection>?>
/**
* Retrieves the stored value of whether the Premium upgrade banner has been dismissed.
*/
@@ -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<String, MutableSharedFlow<Boolean?>>()
private val mutableCollapsedVaultSectionsFlowMap =
mutableMapOf<String, MutableSharedFlow<Set<VaultSection>?>>()
private val mutableVaultPolicyBannerDismissedDateFlowMap =
mutableMapOf<String, MutableSharedFlow<Instant?>>()
@@ -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<VaultSection>? =
getString(key = COLLAPSED_VAULT_SECTIONS.appendIdentifier(identifier = userId))
?.let { json.decodeFromStringOrNull<Set<String>>(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<VaultSection>?,
) {
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<Set<VaultSection>?> =
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<Set<VaultSection>?> =
mutableCollapsedVaultSectionsFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePremiumUpgradeBannerDismissedFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
@@ -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<VaultSection>
/**
* Gets updates for the set of collapsed vault sections for the active user.
*/
fun getCollapsedVaultSectionsFlow(): Flow<Set<VaultSection>>
/**
* 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.
*/
@@ -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<VaultSection>
get() = activeUserId
?.let { settingsDiskSource.getCollapsedVaultSections(userId = it) }
.orEmpty()
override fun getCollapsedVaultSectionsFlow(): Flow<Set<VaultSection>> {
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<Boolean> {
val userId = activeUserId ?: return MutableStateFlow(value = false)
return settingsDiskSource
@@ -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"),
}
File diff suppressed because it is too large Load Diff
@@ -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(),
)
@@ -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<String>,
val collapsedSections: ImmutableSet<VaultSection> = 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<VaultSection>,
) : Internal()
/**
* Indicates that the Premium upgrade banner eligibility has been updated.
*/
@@ -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))
},
)
}
}
@@ -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`() {
@@ -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<String, Boolean?>()
private val storedPullToRefreshEnabled = mutableMapOf<String, Boolean?>()
private var storedIntroducingArchiveActionCardDismissed = mutableMapOf<String, Boolean?>()
private var storedCollapsedVaultSections = mutableMapOf<String, Set<VaultSection>?>()
private var storedPremiumUpgradeBannerDismissed = mutableMapOf<String, Boolean?>()
private val storedVaultPolicyBannerDismissedDate = mutableMapOf<String, Instant?>()
private val storedUpgradedToPremiumCardConsumed = mutableMapOf<String, Boolean?>()
@@ -124,6 +127,9 @@ class FakeSettingsDiskSource(
private val mutableIntroducingArchiveActionCardDismissedFlow =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableCollapsedVaultSectionsFlow =
mutableMapOf<String, MutableSharedFlow<Set<VaultSection>?>>()
private val mutablePremiumUpgradeBannerDismissedFlow =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
@@ -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<VaultSection>? =
storedCollapsedVaultSections[userId]
override fun storeCollapsedVaultSections(
userId: String,
collapsedVaultSections: Set<VaultSection>?,
) {
storedCollapsedVaultSections[userId] = collapsedVaultSections
getMutableCollapsedVaultSectionsFlow(userId = userId).tryEmit(collapsedVaultSections)
}
override fun getCollapsedVaultSectionsFlow(userId: String): Flow<Set<VaultSection>?> =
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<VaultSection>?) {
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<Set<VaultSection>?> =
mutableCollapsedVaultSectionsFlow.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePremiumUpgradeBannerDismissedFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
@@ -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<VaultSection>(), 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<VaultSection>(), settingsRepository.collapsedVaultSections)
settingsRepository.getCollapsedVaultSectionsFlow().test {
assertEquals(emptySet<VaultSection>(), awaitItem())
awaitComplete()
}
}
@Test
fun `getCollapsedVaultSectionsFlow should react to changes in SettingsDiskSource`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
settingsRepository
.getCollapsedVaultSectionsFlow()
.test {
assertEquals(emptySet<VaultSection>(), awaitItem())
fakeSettingsDiskSource.storeCollapsedVaultSections(
userId = USER_ID,
collapsedVaultSections = setOf(VaultSection.TOTP),
)
assertEquals(setOf(VaultSection.TOTP), awaitItem())
fakeSettingsDiskSource.storeCollapsedVaultSections(
userId = USER_ID,
collapsedVaultSections = null,
)
assertEquals(emptySet<VaultSection>(), awaitItem())
}
}
@Test
fun `dismissPremiumUpgradeBanner should properly update SettingsDiskSource`() {
fakeAuthDiskSource.userState = MOCK_USER_STATE
@@ -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
@@ -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<Set<VaultSection>>(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`() =
@@ -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()
@@ -93,6 +93,7 @@ private fun LazyListScope.sharedCodes(
item {
BitwardenListHeaderText(
label = section.label(),
supportingLabel = section.codes.size.toString(),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
@@ -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
@@ -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
@@ -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(
@@ -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(
@@ -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,
@@ -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),
)
}
}
}
@@ -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(),
)
}
}
}
@@ -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),
)
}
}
}
+1
View File
@@ -1143,6 +1143,7 @@ Do you want to switch to this account?</string>
<string name="save_option_as_default">Save option as default</string>
<string name="account_synced_from_bitwarden_app">Account synced from Bitwarden app</string>
<string name="local_codes">Local codes (%1$d)</string>
<string name="local_codes_header">Local codes</string>
<string name="required_information_missing">Required Information Missing</string>
<string name="required_information_missing_message">Required info is missing (e.g., ‘services’ or ‘secret’). Check your file and try again. Visit bitwarden.com/help for support</string>
<string name="file_could_not_be_processed">File Could Not Be Processed</string>
@@ -15,7 +15,7 @@
<string name="fastmail" translatable="false">Fastmail</string>
<string name="forward_email" translatable="false">ForwardEmail</string>
<string name="x_ellipses" translatable="false">%s…</string>
<string name="shared_accounts_header" translatable="false">%1$s | %2$s (%3$d)</string>
<string name="shared_accounts_header" translatable="false">%1$s | %2$s</string>
<!-- region Debug Menu -->
<string name="feature_flags">Feature Flags</string>