mirror of
https://github.com/bitwarden/android.git
synced 2026-11-09 10:34:18 -06:00
[PM-35396] feat: Add expand/collapse to vault section headers (#7504)
This commit is contained in:
+22
@@ -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.
|
||||
*/
|
||||
|
||||
+39
@@ -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?> =
|
||||
|
||||
+16
@@ -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.
|
||||
*/
|
||||
|
||||
+28
@@ -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
|
||||
|
||||
+45
@@ -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.
|
||||
*/
|
||||
|
||||
+5
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -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`() {
|
||||
|
||||
+36
@@ -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?> =
|
||||
|
||||
+64
@@ -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
|
||||
|
||||
+100
-11
@@ -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`() =
|
||||
|
||||
+7
-10
@@ -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()
|
||||
|
||||
+1
@@ -93,6 +93,7 @@ private fun LazyListScope.sharedCodes(
|
||||
item {
|
||||
BitwardenListHeaderText(
|
||||
label = section.label(),
|
||||
supportingLabel = section.codes.size.toString(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
-1
@@ -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(
|
||||
|
||||
-4
@@ -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(
|
||||
|
||||
-4
@@ -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,
|
||||
|
||||
+11
-57
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user