BIT-534: Adding UI and tests for the the vault list screen (#216)

This commit is contained in:
joshua-livefront 2023-11-08 11:48:38 -05:00 committed by Álison Fernandes
parent 887fe99746
commit 9c82e575a1
9 changed files with 974 additions and 107 deletions

View file

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.ui.platform.base.util
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.core.graphics.toColorInt
import java.net.URI
/**
@ -27,3 +29,16 @@ fun String.isValidUri(): Boolean =
* Returns the [String] as an [AnnotatedString].
*/
fun String.toAnnotatedString(): AnnotatedString = AnnotatedString(text = this)
/**
* Converts a hex string to a [Color].
*
* Supported formats:
* - "rrggbb" / "#rrggbb"
* - "aarrggbb" / "#aarrggbb"
*/
fun String.hexToColor(): Color = if (startsWith("#")) {
Color(toColorInt())
} else {
Color("#$this".toColorInt())
}

View file

@ -1,28 +1,233 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.components.BitwardenListHeaderTextWithSupportLabel
/**
* Content view for the [VaultScreen].
*/
@Composable
fun VaultContent(paddingValues: PaddingValues) {
// TODO create proper VaultContentView in BIT-205
Column(
@Suppress("LongMethod")
fun VaultContent(
state: VaultState.ViewState.Content,
vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit,
folderClick: (VaultState.ViewState.FolderItem) -> Unit,
loginGroupClick: () -> Unit,
cardGroupClick: () -> Unit,
identityGroupClick: () -> Unit,
secureNoteGroupClick: () -> Unit,
trashClick: () -> Unit,
paddingValues: PaddingValues,
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = "Vault Content View")
if (state.favoriteItems.isNotEmpty()) {
item {
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.favorites),
supportingLabel = state.favoriteItems.count().toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(4.dp))
}
items(state.favoriteItems) { favoriteItem ->
VaultEntryListItem(
startIcon = painterResource(id = favoriteItem.startIcon),
label = favoriteItem.name(),
supportingLabel = favoriteItem.supportingLabel?.invoke(),
onClick = { vaultItemClick(favoriteItem) },
modifier = Modifier.padding(horizontal = 16.dp),
)
}
item {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.padding(all = 16.dp),
)
}
}
item {
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.types),
supportingLabel = "4",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(4.dp))
}
item {
VaultGroupListItem(
startIcon = painterResource(id = R.drawable.ic_login_item),
label = stringResource(id = R.string.type_login),
supportingLabel = state.loginItemsCount.toString(),
onClick = loginGroupClick,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
item {
VaultGroupListItem(
startIcon = painterResource(id = R.drawable.ic_card_item),
label = stringResource(id = R.string.type_card),
supportingLabel = state.cardItemsCount.toString(),
onClick = cardGroupClick,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
item {
VaultGroupListItem(
startIcon = painterResource(id = R.drawable.ic_identity_item),
label = stringResource(id = R.string.type_identity),
supportingLabel = state.identityItemsCount.toString(),
onClick = identityGroupClick,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
item {
VaultGroupListItem(
startIcon = painterResource(id = R.drawable.ic_secure_note_item),
label = stringResource(id = R.string.type_secure_note),
supportingLabel = state.secureNoteItemsCount.toString(),
onClick = secureNoteGroupClick,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (state.folderItems.isNotEmpty()) {
item {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.padding(all = 16.dp),
)
}
item {
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.folder),
supportingLabel = state.folderItems.count().toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(4.dp))
}
items(state.folderItems) { folder ->
VaultGroupListItem(
startIcon = painterResource(id = R.drawable.ic_folder),
label = folder.name(),
supportingLabel = state.folderItems.count().toString(),
onClick = { folderClick(folder) },
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
if (state.noFolderItems.isNotEmpty()) {
item {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.padding(all = 16.dp),
)
}
item {
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.folder_none),
supportingLabel = state.noFolderItems.count().toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(state.noFolderItems) { noFolderItem ->
VaultEntryListItem(
startIcon = painterResource(id = noFolderItem.startIcon),
label = noFolderItem.name(),
supportingLabel = noFolderItem.supportingLabel?.invoke(),
onClick = { vaultItemClick(noFolderItem) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
item {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.padding(all = 16.dp),
)
}
item {
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.trash),
supportingLabel = state.trashItemsCount.toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(4.dp))
}
item {
VaultGroupListItem(
startIcon = painterResource(id = R.drawable.ic_trash),
label = stringResource(id = R.string.trash),
supportingLabel = state.trashItemsCount.toString(),
onClick = trashClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item { Spacer(modifier = Modifier.height(88.dp)) }
}
}

View file

@ -0,0 +1,95 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A Composable function that displays a row item for different types of vault entries.
*
* @param label The primary text label to display for the item.
* @param supportingLabel An optional secondary text label to display beneath the primary label.
* @param onClick The lambda to be invoked when the item is clicked.
* @param modifier An optional [Modifier] for this Composable, defaulting to an empty Modifier.
* This allows the caller to specify things like padding, size, etc.
*/
@Composable
fun VaultEntryListItem(
startIcon: Painter,
label: String,
supportingLabel: String? = null,
onClick: () -> Unit,
modifier: Modifier,
) {
Row(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = onClick,
)
.padding(vertical = 16.dp)
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
painter = startIcon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
supportingLabel?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(
painter = painterResource(id = R.drawable.ic_more_horizontal),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Preview(showBackground = true)
@Composable
private fun VaultEntryListItem_preview() {
BitwardenTheme {
VaultEntryListItem(
startIcon = painterResource(id = R.drawable.ic_login_item),
label = "Example Login",
supportingLabel = "Username",
onClick = {},
modifier = Modifier,
)
}
}

View file

@ -21,7 +21,7 @@ import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A composable function that displays a list item.
* A composable function that displays a group list item.
* The list item consists of a start icon, a label, a supporting label, and a trailing icon.
*
* @param startIcon The [Painter] object used to draw the icon at the start of the list item.
@ -31,7 +31,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* @param modifier The [Modifier] to be applied to the [Row] composable that holds the list item.
*/
@Composable
fun VaultListItem(
fun VaultGroupListItem(
startIcon: Painter,
label: String,
supportingLabel: String,
@ -79,9 +79,9 @@ fun VaultListItem(
@Preview(showBackground = true)
@Composable
private fun VaultListItem_preview() {
private fun VaultGroupListItem_preview() {
BitwardenTheme {
VaultListItem(
VaultGroupListItem(
startIcon = painterResource(id = R.drawable.ic_login_item),
label = "Main Text",
supportingLabel = "100",

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -34,6 +35,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
/**
* The vault screen for the application.
*/
@Suppress("LongMethod")
@Composable
fun VaultScreen(
viewModel: VaultViewModel = hiltViewModel(),
@ -46,15 +48,83 @@ fun VaultScreen(
VaultEvent.NavigateToVaultSearchScreen -> {
// TODO Create vault search screen and navigation implementation BIT-213
Toast.makeText(context, "Navigate to the vault search screen.", Toast.LENGTH_SHORT)
Toast
.makeText(context, "Navigate to the vault search screen.", Toast.LENGTH_SHORT)
.show()
}
is VaultEvent.NavigateToVaultItem -> {
Toast
.makeText(context, "Navigate to the item details screen", Toast.LENGTH_SHORT)
.show()
}
VaultEvent.NavigateToCardGroup -> {
Toast
.makeText(context, "Navigate to card type screen.", Toast.LENGTH_SHORT)
.show()
}
is VaultEvent.NavigateToFolder -> {
Toast
.makeText(context, "Navigate to folder screen.", Toast.LENGTH_SHORT)
.show()
}
VaultEvent.NavigateToIdentityGroup -> {
Toast
.makeText(context, "Navigate to identity type screen.", Toast.LENGTH_SHORT)
.show()
}
VaultEvent.NavigateToLoginGroup -> {
Toast
.makeText(context, "Navigate to login type screen.", Toast.LENGTH_SHORT)
.show()
}
VaultEvent.NavigateToSecureNotesGroup -> {
Toast
.makeText(context, "Navigate to secure notes type screen.", Toast.LENGTH_SHORT)
.show()
}
VaultEvent.NavigateToTrash -> {
Toast
.makeText(context, "Navigate to trash screen.", Toast.LENGTH_SHORT)
.show()
}
}
}
VaultScreenScaffold(
state = viewModel.stateFlow.collectAsState().value,
addItemClickAction = { viewModel.trySendAction(VaultAction.AddItemClick) },
searchIconClickAction = { viewModel.trySendAction(VaultAction.SearchIconClick) },
addItemClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.AddItemClick) }
},
searchIconClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.SearchIconClick) }
},
vaultItemClick = remember(viewModel) {
{ vaultItem -> viewModel.trySendAction(VaultAction.VaultItemClick(vaultItem)) }
},
folderClick = remember(viewModel) {
{ folderItem -> viewModel.trySendAction(VaultAction.FolderClick(folderItem)) }
},
loginGroupClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.LoginGroupClick) }
},
cardGroupClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.CardGroupClick) }
},
identityGroupClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.IdentityGroupClick) }
},
secureNoteGroupClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.SecureNoteGroupClick) }
},
trashClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.TrashClick) }
},
)
}
@ -68,6 +138,13 @@ private fun VaultScreenScaffold(
state: VaultState,
addItemClickAction: () -> Unit,
searchIconClickAction: () -> Unit,
vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit,
folderClick: (VaultState.ViewState.FolderItem) -> Unit,
loginGroupClick: () -> Unit,
cardGroupClick: () -> Unit,
identityGroupClick: () -> Unit,
secureNoteGroupClick: () -> Unit,
trashClick: () -> Unit,
) {
// TODO Create account menu and logging in ability BIT-205
var accountMenuVisible by rememberSaveable {
@ -116,8 +193,19 @@ private fun VaultScreenScaffold(
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
when (state.viewState) {
is VaultState.ViewState.Content -> VaultContent(paddingValues = paddingValues)
when (val viewState = state.viewState) {
is VaultState.ViewState.Content -> VaultContent(
state = viewState,
vaultItemClick = vaultItemClick,
folderClick = folderClick,
loginGroupClick = loginGroupClick,
cardGroupClick = cardGroupClick,
identityGroupClick = identityGroupClick,
secureNoteGroupClick = secureNoteGroupClick,
trashClick = trashClick,
paddingValues = paddingValues,
)
is VaultState.ViewState.Loading -> VaultLoading(paddingValues = paddingValues)
is VaultState.ViewState.NoItems -> VaultNoItems(
paddingValues = paddingValues,

View file

@ -1,34 +1,72 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Manages [VaultState], handles [VaultAction], and launches [VaultEvent] for the [VaultScreen].
*/
@HiltViewModel
class VaultViewModel @Inject constructor() : BaseViewModel<VaultState, VaultEvent, VaultAction>(
class VaultViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
// TODO retrieve this from the data layer BIT-205
initialState = VaultState(
initialState = savedStateHandle[KEY_STATE] ?: VaultState(
initials = "BW",
avatarColor = Color.Blue,
avatarColorString = "FF0000FF",
viewState = VaultState.ViewState.Loading,
),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
viewModelScope.launch {
// TODO will need to load actual vault items BIT-205
@Suppress("MagicNumber")
delay(2000)
mutableStateFlow.update { currentState ->
currentState.copy(viewState = VaultState.ViewState.NoItems)
currentState.copy(
viewState = VaultState.ViewState.Content(
loginItemsCount = 0,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = emptyList(),
folderItems = listOf(
VaultState.ViewState.FolderItem(
id = null,
name = R.string.folder_none.asText(),
itemCount = 0,
),
),
// TODO Take into account the max threshold of no folder as well as the
// case where it is empty, in which case, no folder is a folder. BIT-205
noFolderItems = emptyList(),
trashItemsCount = 0,
),
)
}
}
}
@ -36,7 +74,14 @@ class VaultViewModel @Inject constructor() : BaseViewModel<VaultState, VaultEven
override fun handleAction(action: VaultAction) {
when (action) {
VaultAction.AddItemClick -> handleAddItemClick()
VaultAction.CardGroupClick -> handleCardClick()
is VaultAction.FolderClick -> handleFolderItemClick(action)
VaultAction.IdentityGroupClick -> handleIdentityClick()
VaultAction.LoginGroupClick -> handleLoginClick()
VaultAction.SearchIconClick -> handleSearchIconClick()
VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
VaultAction.TrashClick -> handleTrashClick()
is VaultAction.VaultItemClick -> handleVaultItemClick(action)
}
}
@ -45,46 +90,220 @@ class VaultViewModel @Inject constructor() : BaseViewModel<VaultState, VaultEven
sendEvent(VaultEvent.NavigateToAddItemScreen)
}
private fun handleCardClick() {
sendEvent(VaultEvent.NavigateToCardGroup)
}
private fun handleFolderItemClick(action: VaultAction.FolderClick) {
sendEvent(VaultEvent.NavigateToFolder(action.folderItem.id))
}
private fun handleIdentityClick() {
sendEvent(VaultEvent.NavigateToIdentityGroup)
}
private fun handleLoginClick() {
sendEvent(VaultEvent.NavigateToLoginGroup)
}
private fun handleSearchIconClick() {
sendEvent(VaultEvent.NavigateToVaultSearchScreen)
}
private fun handleTrashClick() {
sendEvent(VaultEvent.NavigateToTrash)
}
private fun handleSecureNoteClick() {
sendEvent(VaultEvent.NavigateToSecureNotesGroup)
}
private fun handleVaultItemClick(action: VaultAction.VaultItemClick) {
sendEvent(VaultEvent.NavigateToVaultItem(action.vaultItem.id))
}
//endregion VaultAction Handlers
}
/**
* Represents the overall state for the [VaultScreen].
*
* @property avatarColor The color of the avatar in HEX format.
* @property avatarColorString The color of the avatar in HEX format.
* @property initials The initials to be displayed on the avatar.
* @property viewState The specific view state representing loading, no items, or content state.
*/
@Parcelize
data class VaultState(
val avatarColor: Color,
private val avatarColorString: String,
val initials: String,
val viewState: ViewState,
) {
) : Parcelable {
/**
* The [Color] of the avatar.
*/
val avatarColor: Color get() = avatarColorString.hexToColor()
/**
* Represents the specific view states for the [VaultScreen].
*/
sealed class ViewState {
@Parcelize
sealed class ViewState : Parcelable {
/**
* Loading state for the [VaultScreen], signifying that the content is being processed.
*/
@Parcelize
data object Loading : ViewState()
/**
* Represents a state where the [VaultScreen] has no items to display.
*/
@Parcelize
data object NoItems : ViewState()
/**
* Content state for the [VaultScreen] showing the actual content or items.
*
* @property itemList The list of items to be displayed in the [VaultScreen].
* @property loginItemsCount The count of Login type items.
* @property cardItemsCount The count of Card type items.
* @property identityItemsCount The count of Identity type items.
* @property secureNoteItemsCount The count of Secure Notes type items.
* @property favoriteItems The list of favorites to be displayed.
* @property folderItems The list of folders to be displayed.
* @property noFolderItems The list of non-folders to be displayed.
* @property trashItemsCount The number of items present in the trash.
*/
data class Content(val itemList: List<String>) : ViewState()
@Parcelize
data class Content(
val loginItemsCount: Int,
val cardItemsCount: Int,
val identityItemsCount: Int,
val secureNoteItemsCount: Int,
val favoriteItems: List<VaultItem>,
val folderItems: List<FolderItem>,
val noFolderItems: List<VaultItem>,
val trashItemsCount: Int,
) : ViewState()
/**
* Represents a folder item with a name and item count.
*
* @property id The unique identifier for this folder or null to indicate that it is
* "no folder".
* @property name The display name of this folder.
* @property itemCount The number of items this folder contains.
*/
@Parcelize
data class FolderItem(
val id: String?,
val name: Text,
val itemCount: Int,
) : Parcelable
/**
* A sealed class hierarchy representing different types of items in the vault.
*/
@Parcelize
sealed class VaultItem : Parcelable {
/**
* The unique identifier for this item.
*/
abstract val id: String
/**
* The display name of the vault item.
*/
abstract val name: Text
@get:DrawableRes
abstract val startIcon: Int
/**
* An optional supporting label for the vault item that provides additional information.
* This property is open to be overridden by subclasses that can provide their own
* supporting label relevant to the item's type.
*/
abstract val supportingLabel: Text?
/**
* Represents a login item within the vault.
*
* @property username The username associated with this login item.
*/
@Parcelize
data class Login(
override val id: String,
override val name: Text,
val username: Text?,
) : VaultItem() {
override val startIcon: Int get() = R.drawable.ic_login_item
override val supportingLabel: Text? get() = username
}
/**
* Represents a card item within the vault, storing details about a user's payment card.
*
* @property brand The brand of the card, e.g., Visa, MasterCard.
* @property lastFourDigits The last four digits of the card number.
*/
@Parcelize
data class Card(
override val id: String,
override val name: Text,
val brand: Text? = null,
val lastFourDigits: Text? = null,
) : VaultItem() {
override val startIcon: Int get() = R.drawable.ic_card_item
override val supportingLabel: Text?
get() = when {
brand != null && lastFourDigits != null -> brand
.concat(", *".asText(), lastFourDigits)
brand != null -> brand
lastFourDigits != null -> "*".asText().concat(lastFourDigits)
else -> null
}
}
/**
* Represents an identity item within the vault, containing personal identification
* information.
*
* @property firstName The first name of the individual associated with this
* identity item.
*/
@Parcelize
data class Identity(
override val id: String,
override val name: Text,
val firstName: Text?,
) : VaultItem() {
override val startIcon: Int get() = R.drawable.ic_identity_item
override val supportingLabel: Text? get() = firstName
}
/**
* Represents a secure note item within the vault, designed to store secure,
* non-categorized textual information.
*/
@Parcelize
data class SecureNote(
override val id: String,
override val name: Text,
) : VaultItem() {
override val startIcon: Int get() = R.drawable.ic_secure_note_item
override val supportingLabel: Text? get() = null
}
}
}
companion object {
/**
* The maximum number of no folder items that can be displayed before the UI creates a
* no folder "folder".
*/
private const val NO_FOLDER_ITEM_THRESHOLD: Int = 100
}
}
@ -101,6 +320,45 @@ sealed class VaultEvent {
* Navigate to the Add Item screen.
*/
data object NavigateToAddItemScreen : VaultEvent()
/**
* Navigate to the item details screen.
*/
data class NavigateToVaultItem(
val itemId: String,
) : VaultEvent()
/**
* Navigate to the card group screen.
*/
data object NavigateToCardGroup : VaultEvent()
/**
* Navigate to the folder screen.
*/
data class NavigateToFolder(
val folderId: String?,
) : VaultEvent()
/**
* Navigate to the identity group screen.
*/
data object NavigateToIdentityGroup : VaultEvent()
/**
* Navigate to the login group screen.
*/
data object NavigateToLoginGroup : VaultEvent()
/**
* Navigate to the trash screen.
*/
data object NavigateToTrash : VaultEvent()
/**
* Navigate to the secure notes group screen.
*/
data object NavigateToSecureNotesGroup : VaultEvent()
}
/**
@ -117,4 +375,43 @@ sealed class VaultAction {
* Click the search icon.
*/
data object SearchIconClick : VaultAction()
/**
* Action to trigger when a specific vault item is clicked.
*/
data class VaultItemClick(
val vaultItem: VaultState.ViewState.VaultItem,
) : VaultAction()
/**
* Action to trigger when a specific folder item is clicked.
*/
data class FolderClick(
val folderItem: VaultState.ViewState.FolderItem,
) : VaultAction()
/**
* User clicked the login types button.
*/
data object LoginGroupClick : VaultAction()
/**
* User clicked the card types button.
*/
data object CardGroupClick : VaultAction()
/**
* User clicked the identity types button.
*/
data object IdentityGroupClick : VaultAction()
/**
* User clicked the secure notes types button.
*/
data object SecureNoteGroupClick : VaultAction()
/**
* User clicked the trash button.
*/
data object TrashClick : VaultAction()
}

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M16.25,11.25C15.56,11.25 15,10.69 15,10C15,9.31 15.56,8.75 16.25,8.75C16.94,8.75 17.5,9.31 17.5,10C17.5,10.69 16.94,11.25 16.25,11.25Z"
android:fillColor="#000000"/>
<path
android:pathData="M10,11.25C9.31,11.25 8.75,10.69 8.75,10C8.75,9.31 9.31,8.75 10,8.75C10.69,8.75 11.25,9.31 11.25,10C11.25,10.69 10.69,11.25 10,11.25Z"
android:fillColor="#000000"/>
<path
android:pathData="M3.75,11.25C3.06,11.25 2.5,10.69 2.5,10C2.5,9.31 3.06,8.75 3.75,8.75C4.44,8.75 5,9.31 5,10C5,10.69 4.44,11.25 3.75,11.25Z"
android:fillColor="#000000"/>
</vector>

View file

@ -1,115 +1,165 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class VaultScreenTest : BaseComposeTest() {
@Test
fun `search icon click should send SearchIconClick action`() {
val viewModel = mockk<VaultViewModel>(relaxed = true) {
every { eventFlow } returns emptyFlow()
every { stateFlow } returns MutableStateFlow(
VaultState(
avatarColor = Color.Blue,
initials = "BW",
viewState = VaultState.ViewState.NoItems,
),
private var onNavigateToVaultAddItemScreenCalled = false
private val mutableEventFlow = MutableSharedFlow<VaultEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<VaultViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
VaultScreen(
viewModel = viewModel,
onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true },
)
}
composeTestRule.apply {
setContent {
VaultScreen(
viewModel = viewModel,
onNavigateToVaultAddItemScreen = {},
)
}
onNodeWithContentDescription("Search vault").performClick()
}
}
@Test
fun `search icon click should send SearchIconClick action`() {
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) }
composeTestRule.onNodeWithContentDescription("Search vault").performClick()
verify { viewModel.trySendAction(VaultAction.SearchIconClick) }
}
@Test
fun `floating action button click should send AddItemClick action`() {
val viewModel = mockk<VaultViewModel>(relaxed = true) {
every { eventFlow } returns emptyFlow()
every { stateFlow } returns MutableStateFlow(
VaultState(
avatarColor = Color.Blue,
initials = "BW",
viewState = VaultState.ViewState.NoItems,
),
)
}
composeTestRule.apply {
setContent {
VaultScreen(
viewModel = viewModel,
onNavigateToVaultAddItemScreen = {},
)
}
onNodeWithContentDescription("Add item").performClick()
}
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) }
composeTestRule.onNodeWithContentDescription("Add item").performClick()
verify { viewModel.trySendAction(VaultAction.AddItemClick) }
}
@Test
fun `add an item button click should send AddItemClick action`() {
val viewModel = mockk<VaultViewModel>(relaxed = true) {
every { eventFlow } returns emptyFlow()
every { stateFlow } returns MutableStateFlow(
VaultState(
avatarColor = Color.Blue,
initials = "BW",
viewState = VaultState.ViewState.NoItems,
),
)
}
composeTestRule.apply {
setContent {
VaultScreen(
viewModel = viewModel,
onNavigateToVaultAddItemScreen = {},
)
}
onNodeWithText("Add an Item").performClick()
}
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) }
composeTestRule.onNodeWithText("Add an Item").performClick()
verify { viewModel.trySendAction(VaultAction.AddItemClick) }
}
@Test
fun `NavigateToAddItemScreen event should call onNavigateToVaultAddItemScreen`() {
var onNavigateToVaultAddItemScreenCalled = false
val viewModel = mockk<VaultViewModel>(relaxed = true) {
every { eventFlow } returns flowOf(VaultEvent.NavigateToAddItemScreen)
every { stateFlow } returns MutableStateFlow(
VaultState(
avatarColor = Color.Blue,
initials = "BW",
viewState = VaultState.ViewState.NoItems,
mutableEventFlow.tryEmit(VaultEvent.NavigateToAddItemScreen)
assertTrue(onNavigateToVaultAddItemScreenCalled)
}
@Test
fun `clicking a login item should send LoginGroupClick action`() {
mutableStateFlow.update {
it.copy(
viewState = VaultState.ViewState.Content(
loginItemsCount = 0,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = emptyList(),
folderItems = emptyList(),
noFolderItems = emptyList(),
trashItemsCount = 0,
),
)
}
composeTestRule.setContent {
VaultScreen(
onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true },
viewModel = viewModel,
composeTestRule.onNodeWithText("Login").performScrollTo().performClick()
verify {
viewModel.trySendAction(VaultAction.LoginGroupClick)
}
}
@Test
fun `clicking a card item should send CardGroupClick action`() {
mutableStateFlow.update {
it.copy(
viewState = VaultState.ViewState.Content(
loginItemsCount = 0,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = emptyList(),
folderItems = emptyList(),
noFolderItems = emptyList(),
trashItemsCount = 0,
),
)
}
assertTrue(onNavigateToVaultAddItemScreenCalled)
composeTestRule.onNodeWithText("Card").performScrollTo().performClick()
verify {
viewModel.trySendAction(VaultAction.CardGroupClick)
}
}
@Test
fun `clicking an identity item should send IdentityGroupClick action`() {
mutableStateFlow.update {
it.copy(
viewState = VaultState.ViewState.Content(
loginItemsCount = 0,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = emptyList(),
folderItems = emptyList(),
noFolderItems = emptyList(),
trashItemsCount = 0,
),
)
}
composeTestRule.onNodeWithText("Identity").performScrollTo().performClick()
verify {
viewModel.trySendAction(VaultAction.IdentityGroupClick)
}
}
@Test
fun `clicking a secure note item should send SecureNoteGroupClick action`() {
mutableStateFlow.update {
it.copy(
viewState = VaultState.ViewState.Content(
loginItemsCount = 0,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = emptyList(),
folderItems = emptyList(),
noFolderItems = emptyList(),
trashItemsCount = 0,
),
)
}
composeTestRule.onNodeWithText("Secure note").performScrollTo().performClick()
verify {
viewModel.trySendAction(VaultAction.SecureNoteGroupClick)
}
}
}
private val DEFAULT_STATE: VaultState = VaultState(
avatarColorString = "FF0000FF",
initials = "BW",
viewState = VaultState.ViewState.Loading,
)

View file

@ -1,7 +1,10 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -9,8 +12,24 @@ import org.junit.jupiter.api.Test
class VaultViewModelTest : BaseViewModelTest() {
@Test
fun `AddItemClick should navigate to the add item screen`() = runTest {
val viewModel = VaultViewModel()
fun `initial state should be correct when not set`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should be correct when set`() {
val state = DEFAULT_STATE.copy(
initials = "WB",
avatarColorString = "00FF00",
)
val viewModel = createViewModel(state = state)
assertEquals(state, viewModel.stateFlow.value)
}
@Test
fun `AddItemClick should emit NavigateToAddItemScreen`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.AddItemClick)
assertEquals(VaultEvent.NavigateToAddItemScreen, awaitItem())
@ -18,11 +37,94 @@ class VaultViewModelTest : BaseViewModelTest() {
}
@Test
fun `SearchIconClick should navigate to the vault search screen`() = runTest {
val viewModel = VaultViewModel()
fun `CardGroupClick should emit NavigateToCardGroup`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.CardGroupClick)
assertEquals(VaultEvent.NavigateToCardGroup, awaitItem())
}
}
@Test
fun `FolderClick should emit NavigateToFolder with correct folder ID`() = runTest {
val viewModel = createViewModel()
val folderId = "12345"
val folder = mockk<VaultState.ViewState.FolderItem> {
every { id } returns folderId
}
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.FolderClick(folder))
assertEquals(VaultEvent.NavigateToFolder(folderId), awaitItem())
}
}
@Test
fun `IdentityGroupClick should emit NavigateToIdentityGroup`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.IdentityGroupClick)
assertEquals(VaultEvent.NavigateToIdentityGroup, awaitItem())
}
}
@Test
fun `LoginGroupClick should emit NavigateToLoginGroup`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.LoginGroupClick)
assertEquals(VaultEvent.NavigateToLoginGroup, awaitItem())
}
}
@Test
fun `SearchIconClick should emit NavigateToVaultSearchScreen`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.SearchIconClick)
assertEquals(VaultEvent.NavigateToVaultSearchScreen, awaitItem())
}
}
@Test
fun `SecureNoteGroupClick should emit NavigateToSecureNotesGroup`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.SecureNoteGroupClick)
assertEquals(VaultEvent.NavigateToSecureNotesGroup, awaitItem())
}
}
@Test
fun `TrashClick should emit NavigateToTrash`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.TrashClick)
assertEquals(VaultEvent.NavigateToTrash, awaitItem())
}
}
@Test
fun `VaultItemClick should emit NavigateToVaultItem with the correct item ID`() = runTest {
val viewModel = createViewModel()
val itemId = "54321"
val item = mockk<VaultState.ViewState.VaultItem> {
every { id } returns itemId
}
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.VaultItemClick(item))
assertEquals(VaultEvent.NavigateToVaultItem(itemId), awaitItem())
}
}
private fun createViewModel(
state: VaultState? = DEFAULT_STATE,
): VaultViewModel = VaultViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", state) },
)
}
private val DEFAULT_STATE: VaultState = VaultState(
avatarColorString = "FF0000FF",
initials = "BW",
viewState = VaultState.ViewState.Loading,
)