mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-842, BIT-843: Add Vault Filter and Vault Selection menu UI (#448)
This commit is contained in:
parent
3c29dccf62
commit
3f0e44d42f
11 changed files with 631 additions and 30 deletions
|
@ -0,0 +1,99 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.vault
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.scrolledContainerBackground
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Displays the current [selectedVaultFilterType] and allows for a new selection from the
|
||||
* given [vaultFilterTypes].
|
||||
*
|
||||
* @param selectedVaultFilterType The currently selected filter type.
|
||||
* @param vaultFilterTypes The list of possible filter types.
|
||||
* @param onVaultFilterTypeSelect A callback for when a new type is selected.
|
||||
* @param topAppBarScrollBehavior Used to derive the background color of the content and keep it in
|
||||
* sync with the associated app bar.
|
||||
* @param modifier A [Modifier] for the composable.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VaultFilter(
|
||||
selectedVaultFilterType: VaultFilterType,
|
||||
vaultFilterTypes: ImmutableList<VaultFilterType>,
|
||||
onVaultFilterTypeSelect: (VaultFilterType) -> Unit,
|
||||
topAppBarScrollBehavior: TopAppBarScrollBehavior,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shouldShowSelectionDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (shouldShowSelectionDialog) {
|
||||
BitwardenSelectionDialog(
|
||||
title = stringResource(id = R.string.filter_by_vault),
|
||||
onDismissRequest = { shouldShowSelectionDialog = false },
|
||||
) {
|
||||
vaultFilterTypes.forEach { filterType ->
|
||||
BitwardenSelectionRow(
|
||||
text = filterType.description,
|
||||
isSelected = filterType == selectedVaultFilterType,
|
||||
onClick = {
|
||||
shouldShowSelectionDialog = false
|
||||
onVaultFilterTypeSelect(filterType)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.scrolledContainerBackground(topAppBarScrollBehavior)
|
||||
.padding(vertical = 8.dp)
|
||||
.then(modifier),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.vault_filter_description,
|
||||
selectedVaultFilterType.name(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = { shouldShowSelectionDialog = true },
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_more_horizontal),
|
||||
contentDescription = stringResource(id = R.string.filter_by_vault),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,10 +5,13 @@ import androidx.compose.animation.AnimatedVisibility
|
|||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
|
@ -42,6 +45,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
|
|||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
@ -90,6 +94,9 @@ fun VaultScreen(
|
|||
}
|
||||
VaultScreenScaffold(
|
||||
state = viewModel.stateFlow.collectAsState().value,
|
||||
vaultFilterTypeSelect = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultAction.VaultFilterTypeSelect(it)) }
|
||||
},
|
||||
addItemClickAction = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultAction.AddItemClick) }
|
||||
},
|
||||
|
@ -161,6 +168,7 @@ fun VaultScreen(
|
|||
@Composable
|
||||
private fun VaultScreenScaffold(
|
||||
state: VaultState,
|
||||
vaultFilterTypeSelect: (VaultFilterType) -> Unit,
|
||||
addItemClickAction: () -> Unit,
|
||||
searchIconClickAction: () -> Unit,
|
||||
accountLockClickAction: (AccountSummary) -> Unit,
|
||||
|
@ -230,7 +238,7 @@ private fun VaultScreenScaffold(
|
|||
BitwardenScaffold(
|
||||
topBar = {
|
||||
BitwardenMediumTopAppBar(
|
||||
title = stringResource(id = R.string.my_vault),
|
||||
title = state.appBarTitle(),
|
||||
scrollBehavior = scrollBehavior,
|
||||
actions = {
|
||||
BitwardenAccountActionItem(
|
||||
|
@ -283,10 +291,33 @@ private fun VaultScreenScaffold(
|
|||
},
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
) { paddingValues ->
|
||||
val modifier = Modifier
|
||||
Box {
|
||||
val innerModifier = Modifier
|
||||
.fillMaxSize()
|
||||
val outerModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
Box {
|
||||
Column(modifier = outerModifier) {
|
||||
val vaultFilterData = state.vaultFilterData
|
||||
if (state.viewState.hasVaultFilter && vaultFilterData != null) {
|
||||
VaultFilter(
|
||||
selectedVaultFilterType = vaultFilterData.selectedVaultFilterType,
|
||||
vaultFilterTypes = vaultFilterData.vaultFilterTypes.toImmutableList(),
|
||||
onVaultFilterTypeSelect = vaultFilterTypeSelect,
|
||||
topAppBarScrollBehavior = scrollBehavior,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
when (val viewState = state.viewState) {
|
||||
is VaultState.ViewState.Content -> VaultContent(
|
||||
state = viewState,
|
||||
|
@ -298,22 +329,23 @@ private fun VaultScreenScaffold(
|
|||
identityGroupClick = identityGroupClick,
|
||||
secureNoteGroupClick = secureNoteGroupClick,
|
||||
trashClick = trashClick,
|
||||
modifier = modifier,
|
||||
modifier = innerModifier,
|
||||
)
|
||||
|
||||
is VaultState.ViewState.Loading -> VaultLoading(modifier = modifier)
|
||||
is VaultState.ViewState.Loading -> VaultLoading(modifier = innerModifier)
|
||||
is VaultState.ViewState.NoItems -> VaultNoItems(
|
||||
modifier = modifier,
|
||||
modifier = innerModifier,
|
||||
addItemClickAction = addItemClickAction,
|
||||
)
|
||||
|
||||
is VaultState.ViewState.Error -> BitwardenErrorContent(
|
||||
message = viewState.message(),
|
||||
onTryAgainClick = tryAgainClick,
|
||||
modifier = modifier
|
||||
modifier = innerModifier
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BitwardenAccountSwitcher(
|
||||
isVisible = accountMenuVisible,
|
||||
|
@ -324,7 +356,7 @@ private fun VaultScreenScaffold(
|
|||
onAddAccountClick = addAccountClickAction,
|
||||
onDismissRequest = { updateAccountMenuVisibility(false) },
|
||||
topAppBarScrollBehavior = scrollBehavior,
|
||||
modifier = modifier,
|
||||
modifier = outerModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,9 +17,13 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
|
|||
import com.x8bit.bitwarden.ui.platform.base.util.concat
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.hexToColor
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAppBarTitle
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toVaultFilterData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
@ -45,10 +49,14 @@ class VaultViewModel @Inject constructor(
|
|||
val userState = requireNotNull(authRepository.userStateFlow.value)
|
||||
val accountSummaries = userState.toAccountSummaries()
|
||||
val activeAccountSummary = userState.toActiveAccountSummary()
|
||||
val vaultFilterData = userState.activeAccount.toVaultFilterData()
|
||||
val appBarTitle = vaultFilterData.toAppBarTitle()
|
||||
VaultState(
|
||||
appBarTitle = appBarTitle,
|
||||
initials = activeAccountSummary.initials,
|
||||
avatarColorString = activeAccountSummary.avatarColorHex,
|
||||
accountSummaries = accountSummaries,
|
||||
vaultFilterData = vaultFilterData,
|
||||
viewState = VaultState.ViewState.Loading,
|
||||
)
|
||||
},
|
||||
|
@ -96,6 +104,7 @@ class VaultViewModel @Inject constructor(
|
|||
is VaultAction.SyncClick -> handleSyncClick()
|
||||
is VaultAction.LockClick -> handleLockClick()
|
||||
is VaultAction.ExitConfirmationClick -> handleExitConfirmationClick()
|
||||
is VaultAction.VaultFilterTypeSelect -> handleVaultFilterTypeSelect(action)
|
||||
is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
|
||||
is VaultAction.TrashClick -> handleTrashClick()
|
||||
is VaultAction.VaultItemClick -> handleVaultItemClick(action)
|
||||
|
@ -183,6 +192,16 @@ class VaultViewModel @Inject constructor(
|
|||
sendEvent(VaultEvent.NavigateOutOfApp)
|
||||
}
|
||||
|
||||
private fun handleVaultFilterTypeSelect(action: VaultAction.VaultFilterTypeSelect) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
vaultFilterData = it.vaultFilterData?.copy(
|
||||
selectedVaultFilterType = action.vaultFilterType,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTrashClick() {
|
||||
sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.Trash))
|
||||
}
|
||||
|
@ -214,13 +233,17 @@ class VaultViewModel @Inject constructor(
|
|||
// navigating.
|
||||
if (state.isSwitchingAccounts) return
|
||||
|
||||
val vaultFilterData = userState.activeAccount.toVaultFilterData()
|
||||
val appBarTitle = vaultFilterData.toAppBarTitle()
|
||||
mutableStateFlow.update {
|
||||
val accountSummaries = userState.toAccountSummaries()
|
||||
val activeAccountSummary = userState.toActiveAccountSummary()
|
||||
it.copy(
|
||||
appBarTitle = appBarTitle,
|
||||
initials = activeAccountSummary.initials,
|
||||
avatarColorString = activeAccountSummary.avatarColorHex,
|
||||
accountSummaries = accountSummaries,
|
||||
vaultFilterData = vaultFilterData,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -284,9 +307,11 @@ class VaultViewModel @Inject constructor(
|
|||
*/
|
||||
@Parcelize
|
||||
data class VaultState(
|
||||
val appBarTitle: Text,
|
||||
private val avatarColorString: String,
|
||||
val initials: String,
|
||||
val accountSummaries: List<AccountSummary>,
|
||||
val vaultFilterData: VaultFilterData? = null,
|
||||
val viewState: ViewState,
|
||||
val dialog: DialogState? = null,
|
||||
// Internal-use properties
|
||||
|
@ -310,12 +335,18 @@ data class VaultState(
|
|||
*/
|
||||
abstract val hasFab: Boolean
|
||||
|
||||
/**
|
||||
* Determines whether or not the the Vault Filter may be shown (when applicable).
|
||||
*/
|
||||
abstract val hasVaultFilter: Boolean
|
||||
|
||||
/**
|
||||
* Loading state for the [VaultScreen], signifying that the content is being processed.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Loading : ViewState() {
|
||||
override val hasFab: Boolean get() = false
|
||||
override val hasVaultFilter: Boolean get() = false
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -324,6 +355,7 @@ data class VaultState(
|
|||
@Parcelize
|
||||
data object NoItems : ViewState() {
|
||||
override val hasFab: Boolean get() = true
|
||||
override val hasVaultFilter: Boolean get() = true
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -335,6 +367,7 @@ data class VaultState(
|
|||
val message: Text,
|
||||
) : ViewState() {
|
||||
override val hasFab: Boolean get() = false
|
||||
override val hasVaultFilter: Boolean get() = false
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -363,6 +396,7 @@ data class VaultState(
|
|||
val trashItemsCount: Int,
|
||||
) : ViewState() {
|
||||
override val hasFab: Boolean get() = true
|
||||
override val hasVaultFilter: Boolean get() = true
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -621,6 +655,13 @@ sealed class VaultAction {
|
|||
*/
|
||||
data object ExitConfirmationClick : VaultAction()
|
||||
|
||||
/**
|
||||
* User selected a [VaultFilterType] from the Vault Filter menu.
|
||||
*/
|
||||
data class VaultFilterTypeSelect(
|
||||
val vaultFilterType: VaultFilterType,
|
||||
) : VaultAction()
|
||||
|
||||
/**
|
||||
* Action to trigger when a specific vault item is clicked.
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.vault.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Represents a currently [selectedVaultFilterType] and a list of possible [vaultFilterTypes].
|
||||
*/
|
||||
@Parcelize
|
||||
data class VaultFilterData(
|
||||
val selectedVaultFilterType: VaultFilterType,
|
||||
val vaultFilterTypes: List<VaultFilterType>,
|
||||
) : Parcelable
|
|
@ -0,0 +1,53 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.vault.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Represents a way to filter on vaults when more than one may be present in a list of vault items.
|
||||
*/
|
||||
sealed class VaultFilterType : Parcelable {
|
||||
/**
|
||||
* A short name for the filter.
|
||||
*/
|
||||
abstract val name: Text
|
||||
|
||||
/**
|
||||
* A potentially longer description of the filter. This may be the same as the [name] when there
|
||||
* is no distinction necessary.
|
||||
*/
|
||||
abstract val description: Text
|
||||
|
||||
/**
|
||||
* Data from all vaults should be present (i.e. there is no filtering).
|
||||
*/
|
||||
@Parcelize
|
||||
data object AllVaults : VaultFilterType() {
|
||||
override val name: Text get() = R.string.all.asText()
|
||||
override val description: Text get() = R.string.all_vaults.asText()
|
||||
}
|
||||
|
||||
/**
|
||||
* Only data from the user's personal vault shoudl be present.
|
||||
*/
|
||||
@Parcelize
|
||||
data object MyVault : VaultFilterType() {
|
||||
override val name: Text get() = R.string.my_vault.asText()
|
||||
override val description: Text get() = R.string.my_vault.asText()
|
||||
}
|
||||
|
||||
/**
|
||||
* Only data from the organization with the given [organizationId] should be present.
|
||||
*/
|
||||
@Parcelize
|
||||
data class OrganizationVault(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
) : VaultFilterType() {
|
||||
override val name: Text get() = organizationName.asText()
|
||||
override val description: Text get() = organizationName.asText()
|
||||
}
|
||||
}
|
|
@ -2,6 +2,8 @@ package com.x8bit.bitwarden.ui.vault.feature.vault.util
|
|||
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
|
||||
/**
|
||||
* Converts the given [UserState] to a list of [AccountSummary].
|
||||
|
@ -39,3 +41,30 @@ fun UserState.Account.toAccountSummary(
|
|||
isActive = isActive,
|
||||
isVaultUnlocked = this.isVaultUnlocked,
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts the given [UserState.Account] to a [VaultFilterData] (if applicable). Filter data is
|
||||
* only relevant when the given account is associated with one or more organizations.
|
||||
*/
|
||||
fun UserState.Account.toVaultFilterData(): VaultFilterData? =
|
||||
this
|
||||
.organizations
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { organizations ->
|
||||
VaultFilterData(
|
||||
selectedVaultFilterType = VaultFilterType.AllVaults,
|
||||
vaultFilterTypes = listOf(
|
||||
VaultFilterType.AllVaults,
|
||||
VaultFilterType.MyVault,
|
||||
*organizations
|
||||
.sortedBy { it.name }
|
||||
.map { organization ->
|
||||
VaultFilterType.OrganizationVault(
|
||||
organizationId = organization.id,
|
||||
organizationName = organization.name.orEmpty(),
|
||||
)
|
||||
}
|
||||
.toTypedArray(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.vault.util
|
||||
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
|
||||
|
||||
/**
|
||||
* Derives an app bar title given the [VaultFilterData].
|
||||
*/
|
||||
fun VaultFilterData?.toAppBarTitle(): Text =
|
||||
if (this != null) {
|
||||
R.string.vaults
|
||||
} else {
|
||||
R.string.my_vault
|
||||
}
|
||||
.asText()
|
|
@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
|
|||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
|
@ -32,6 +33,8 @@ import com.x8bit.bitwarden.ui.util.performAddAccountClick
|
|||
import com.x8bit.bitwarden.ui.util.performLockAccountClick
|
||||
import com.x8bit.bitwarden.ui.util.performLogoutAccountClick
|
||||
import com.x8bit.bitwarden.ui.util.performLogoutAccountConfirmationClick
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
@ -80,6 +83,135 @@ class VaultScreenTest : BaseComposeTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `app bar title should update according to state`() {
|
||||
composeTestRule.onNodeWithText("My vault").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Vaults").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(appBarTitle = R.string.vaults.asText())
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("My vault").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Vaults").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vault filter should update according to state`() {
|
||||
composeTestRule.onNodeWithText("Vault: All").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Vault: My vault").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Vault: Test Organization").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
vaultFilterData = VAULT_FILTER_DATA,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Vault: All").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Vault: My vault").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Vault: Test Organization").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
vaultFilterData = VAULT_FILTER_DATA.copy(
|
||||
selectedVaultFilterType = VaultFilterType.MyVault,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Vault: All").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Vault: My vault").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Vault: Test Organization").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
vaultFilterData = VAULT_FILTER_DATA.copy(
|
||||
selectedVaultFilterType = ORGANIZATION_VAULT_FILTER,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Vault: All").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Vault: My vault").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Vault: Test Organization").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vault filter menu click should display the filter selection dialog`() {
|
||||
// Display the vault filter
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
vaultFilterData = VAULT_FILTER_DATA,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription("Filter items by vault").performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("All vaults")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText("My vault")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Test Organization")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel click in the filter selection dialog should close the dialog`() {
|
||||
// Display the vault selection dialog
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
vaultFilterData = VAULT_FILTER_DATA,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
}
|
||||
composeTestRule.onNodeWithContentDescription("Filter items by vault").performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `vault filter click in the filter selection dialog should send VaultFilterTypeSelect and close the dialog`() {
|
||||
// Display the vault selection dialog
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
vaultFilterData = VAULT_FILTER_DATA,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
}
|
||||
composeTestRule.onNodeWithContentDescription("Filter items by vault").performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("All vaults")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultAction.VaultFilterTypeSelect(VaultFilterType.AllVaults))
|
||||
}
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `account icon click should show the account switcher and trigger the nav bar dim request`() {
|
||||
|
@ -858,6 +990,20 @@ private val LOCKED_ACCOUNT_SUMMARY = AccountSummary(
|
|||
isVaultUnlocked = false,
|
||||
)
|
||||
|
||||
private val ORGANIZATION_VAULT_FILTER = VaultFilterType.OrganizationVault(
|
||||
organizationId = "testOrganizationId",
|
||||
organizationName = "Test Organization",
|
||||
)
|
||||
|
||||
private val VAULT_FILTER_DATA = VaultFilterData(
|
||||
selectedVaultFilterType = VaultFilterType.AllVaults,
|
||||
vaultFilterTypes = listOf(
|
||||
VaultFilterType.AllVaults,
|
||||
VaultFilterType.MyVault,
|
||||
ORGANIZATION_VAULT_FILTER,
|
||||
),
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE: VaultState = VaultState(
|
||||
avatarColorString = "#aa00aa",
|
||||
initials = "AU",
|
||||
|
@ -865,6 +1011,7 @@ private val DEFAULT_STATE: VaultState = VaultState(
|
|||
ACTIVE_ACCOUNT_SUMMARY,
|
||||
LOCKED_ACCOUNT_SUMMARY,
|
||||
),
|
||||
appBarTitle = R.string.my_vault.asText(),
|
||||
viewState = VaultState.ViewState.Loading,
|
||||
)
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault
|
|||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState.SpecialCircumstance
|
||||
|
@ -16,6 +17,8 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData
|
|||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
|
@ -126,13 +129,19 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
environment = Environment.Us,
|
||||
isPremium = true,
|
||||
isVaultUnlocked = true,
|
||||
organizations = emptyList(),
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
id = "organiationId",
|
||||
name = "Test Organization",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
appBarTitle = R.string.vaults.asText(),
|
||||
avatarColorString = "#00aaaa",
|
||||
initials = "OU",
|
||||
accountSummaries = listOf(
|
||||
|
@ -146,6 +155,17 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
isVaultUnlocked = true,
|
||||
),
|
||||
),
|
||||
vaultFilterData = VaultFilterData(
|
||||
selectedVaultFilterType = VaultFilterType.AllVaults,
|
||||
vaultFilterTypes = listOf(
|
||||
VaultFilterType.AllVaults,
|
||||
VaultFilterType.MyVault,
|
||||
VaultFilterType.OrganizationVault(
|
||||
organizationId = "organiationId",
|
||||
organizationName = "Test Organization",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
@ -286,6 +306,46 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on VaultFilterTypeSelect should update the selected filter type`() {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Update to state with filters
|
||||
val initialState = DEFAULT_STATE.copy(
|
||||
appBarTitle = R.string.vaults.asText(),
|
||||
vaultFilterData = VAULT_FILTER_DATA,
|
||||
)
|
||||
mutableUserStateFlow.value = DEFAULT_USER_STATE
|
||||
.copy(
|
||||
accounts = listOf(
|
||||
DEFAULT_USER_STATE.accounts[0].copy(
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
id = "testOrganizationId",
|
||||
name = "Test Organization",
|
||||
),
|
||||
),
|
||||
),
|
||||
DEFAULT_USER_STATE.accounts[1],
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
initialState,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
viewModel.trySendAction(VaultAction.VaultFilterTypeSelect(VaultFilterType.MyVault))
|
||||
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
vaultFilterData = VAULT_FILTER_DATA.copy(
|
||||
selectedVaultFilterType = VaultFilterType.MyVault,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Loaded with items should update state to Content`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
|
@ -760,6 +820,20 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
private val ORGANIZATION_VAULT_FILTER = VaultFilterType.OrganizationVault(
|
||||
organizationId = "testOrganizationId",
|
||||
organizationName = "Test Organization",
|
||||
)
|
||||
|
||||
private val VAULT_FILTER_DATA = VaultFilterData(
|
||||
selectedVaultFilterType = VaultFilterType.AllVaults,
|
||||
vaultFilterTypes = listOf(
|
||||
VaultFilterType.AllVaults,
|
||||
VaultFilterType.MyVault,
|
||||
ORGANIZATION_VAULT_FILTER,
|
||||
),
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE: VaultState =
|
||||
createMockVaultState(viewState = VaultState.ViewState.Loading)
|
||||
|
||||
|
@ -794,6 +868,7 @@ private fun createMockVaultState(
|
|||
dialog: VaultState.DialogState? = null,
|
||||
): VaultState =
|
||||
VaultState(
|
||||
appBarTitle = R.string.my_vault.asText(),
|
||||
avatarColorString = "#aa00aa",
|
||||
initials = "AU",
|
||||
accountSummaries = listOf(
|
||||
|
|
|
@ -5,7 +5,10 @@ import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class UserStateExtensionsTest {
|
||||
|
@ -197,4 +200,63 @@ class UserStateExtensionsTest {
|
|||
.toActiveAccountSummary(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toVaultFilterData for an account with no organizations should return a null value`() {
|
||||
assertNull(
|
||||
UserState.Account(
|
||||
userId = "activeUserId",
|
||||
name = "name",
|
||||
email = "email",
|
||||
avatarColorHex = "avatarColorHex",
|
||||
environment = Environment.Us,
|
||||
isPremium = true,
|
||||
isVaultUnlocked = true,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
.toVaultFilterData(),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `toVaultFilterData for an account with organizations should return data with the available types in the correct order`() {
|
||||
assertEquals(
|
||||
VaultFilterData(
|
||||
selectedVaultFilterType = VaultFilterType.AllVaults,
|
||||
vaultFilterTypes = listOf(
|
||||
VaultFilterType.AllVaults,
|
||||
VaultFilterType.MyVault,
|
||||
VaultFilterType.OrganizationVault(
|
||||
organizationId = "organizationId-A",
|
||||
organizationName = "Organization A",
|
||||
),
|
||||
VaultFilterType.OrganizationVault(
|
||||
organizationId = "organizationId-B",
|
||||
organizationName = "Organization B",
|
||||
),
|
||||
),
|
||||
),
|
||||
UserState.Account(
|
||||
userId = "activeUserId",
|
||||
name = "name",
|
||||
email = "email",
|
||||
avatarColorHex = "avatarColorHex",
|
||||
environment = Environment.Us,
|
||||
isPremium = true,
|
||||
isVaultUnlocked = true,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
id = "organizationId-B",
|
||||
name = "Organization B",
|
||||
),
|
||||
Organization(
|
||||
id = "organizationId-A",
|
||||
name = "Organization A",
|
||||
),
|
||||
),
|
||||
)
|
||||
.toVaultFilterData(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.vault.util
|
||||
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class VaultFilterDataExtensionsTest {
|
||||
@Test
|
||||
fun `toAppBarTitle for a null value should return My Vault`() {
|
||||
assertEquals(
|
||||
R.string.my_vault.asText(),
|
||||
(null as VaultFilterData?).toAppBarTitle(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toAppBarTitle for a non-null value should return Vaults`() {
|
||||
assertEquals(
|
||||
R.string.vaults.asText(),
|
||||
VaultFilterData(
|
||||
selectedVaultFilterType = VaultFilterType.MyVault,
|
||||
vaultFilterTypes = listOf(
|
||||
VaultFilterType.AllVaults,
|
||||
VaultFilterType.MyVault,
|
||||
),
|
||||
)
|
||||
.toAppBarTitle(),
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue