mirror of
https://github.com/bitwarden/android.git
synced 2025-02-16 11:59:57 +03:00
BIT-534: Adding UI and tests for the the vault list screen (#216)
This commit is contained in:
parent
887fe99746
commit
9c82e575a1
9 changed files with 974 additions and 107 deletions
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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",
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
15
app/src/main/res/drawable/ic_more_horizontal.xml
Normal file
15
app/src/main/res/drawable/ic_more_horizontal.xml
Normal 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>
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue