BIT-956: UI for item listing screen (#356)

This commit is contained in:
Ramsey Smith 2023-12-11 12:39:24 -07:00 committed by Álison Fernandes
parent dd37721e51
commit b7578b8f96
10 changed files with 1228 additions and 10 deletions

View file

@ -0,0 +1,49 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
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
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultEntryListItem
/**
* Content view for the [VaultItemListingScreen].
*/
@Composable
fun VaultItemListingContent(
state: VaultItemListingState.ViewState.Content,
vaultItemClick: (id: String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
) {
item {
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.items),
supportingLabel = state.displayItemList.size.toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(state.displayItemList) {
VaultEntryListItem(
startIcon = painterResource(id = it.iconRes),
label = it.title,
supportingLabel = it.subtitle,
onClick = { vaultItemClick(it.id) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
}

View file

@ -0,0 +1,72 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultNoItems
/**
* Empty view for the [VaultItemListingScreen].
*/
@Composable
fun VaultItemListingEmpty(
paddingValues: PaddingValues,
itemListingType: VaultItemListingState.ItemListingType,
addItemClickAction: () -> Unit,
modifier: Modifier = Modifier,
) {
when (itemListingType) {
is VaultItemListingState.ItemListingType.Folder -> {
GenericNoItems(
modifier = modifier,
text = stringResource(id = R.string.no_items_folder),
)
}
is VaultItemListingState.ItemListingType.Trash -> {
GenericNoItems(
modifier = modifier,
text = stringResource(id = R.string.no_items_trash),
)
}
else -> {
VaultNoItems(
paddingValues = paddingValues,
addItemClickAction = addItemClickAction,
)
}
}
}
@Composable
private fun GenericNoItems(
text: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = text,
style = MaterialTheme.typography.bodyMedium,
)
}
}

View file

@ -0,0 +1,52 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
/**
* The top level error UI state for the [VaultItemListingScreen].
*/
@Composable
fun VaultItemListingError(
state: VaultItemListingState.ViewState.Error,
onRefreshClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = state.message(),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextButton(
label = stringResource(id = R.string.try_again),
onClick = onRefreshClick,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(88.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}

View file

@ -0,0 +1,27 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
/**
* Loading view for the [VaultItemListingScreen].
*/
@Composable
fun VaultItemListingLoading(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.navigationBarsPadding())
}
}

View file

@ -1,12 +1,29 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
/**
* Displays the vault item listing screen.
@ -16,12 +33,133 @@ fun VaultItemListingScreen(
onNavigateBack: () -> Unit,
onNavigateToVaultItem: (id: String) -> Unit,
onNavigateToVaultAddItemScreen: () -> Unit,
viewModel: VaultItemListingViewModel = hiltViewModel(),
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = "Listing Screen")
val context = LocalContext.current
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VaultItemListingEvent.NavigateBack -> onNavigateBack()
is VaultItemListingEvent.NavigateToVaultItem -> {
onNavigateToVaultItem(event.id)
}
is VaultItemListingEvent.ShowToast -> {
Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show()
}
is VaultItemListingEvent.NavigateToAddVaultItem -> {
onNavigateToVaultAddItemScreen()
}
is VaultItemListingEvent.NavigateToVaultSearchScreen -> {
// TODO Create vault search screen and navigation implementation BIT-213
Toast
.makeText(context, "Navigate to the vault search screen.", Toast.LENGTH_SHORT)
.show()
}
}
}
VaultItemListingScaffold(
state = viewModel.stateFlow.collectAsState().value,
backClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemListingsAction.BackClick) }
},
searchIconClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemListingsAction.SearchIconClick) }
},
addVaultItemClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) }
},
vaultItemClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemListingsAction.ItemClick(it)) }
},
refreshClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemListingsAction.RefreshClick) }
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
private fun VaultItemListingScaffold(
state: VaultItemListingState,
backClick: () -> Unit,
searchIconClick: () -> Unit,
addVaultItemClick: () -> Unit,
vaultItemClick: (id: String) -> Unit,
refreshClick: () -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = state.itemListingType.titleText(),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_back),
navigationIconContentDescription = stringResource(id = R.string.back),
onNavigationIconClick = backClick,
actions = {
BitwardenSearchActionItem(
contentDescription = stringResource(id = R.string.search_vault),
onClick = searchIconClick,
)
BitwardenOverflowActionItem()
},
)
},
floatingActionButton = {
if (state.itemListingType.hasFab) {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.primaryContainer,
onClick = addVaultItemClick,
) {
Icon(
painter = painterResource(id = R.drawable.ic_plus),
contentDescription = stringResource(id = R.string.add_item),
)
}
}
},
) { paddingValues ->
val modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
when (state.viewState) {
is VaultItemListingState.ViewState.Content -> {
VaultItemListingContent(
state = state.viewState,
vaultItemClick = vaultItemClick,
modifier = modifier,
)
}
is VaultItemListingState.ViewState.NoItems -> {
VaultItemListingEmpty(
paddingValues = paddingValues,
itemListingType = state.itemListingType,
addItemClickAction = addVaultItemClick,
modifier = modifier,
)
}
is VaultItemListingState.ViewState.Error -> {
VaultItemListingError(
state = state.viewState,
onRefreshClick = refreshClick,
modifier = modifier,
)
}
is VaultItemListingState.ViewState.Loading -> {
VaultItemListingLoading(modifier = modifier)
}
}
}
}

View file

@ -0,0 +1,305 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import androidx.annotation.DrawableRes
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.vault.feature.itemlisting.util.toItemListingType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Manages [VaultItemListingState], handles [VaultItemListingsAction],
* and launches [VaultItemListingEvent] for the [VaultItemListingScreen].
*/
@HiltViewModel
@Suppress("MagicNumber")
class VaultItemListingViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<VaultItemListingState, VaultItemListingEvent, VaultItemListingsAction>(
initialState = VaultItemListingState(
itemListingType = VaultItemListingArgs(savedStateHandle = savedStateHandle)
.vaultItemListingType
.toItemListingType(),
viewState = VaultItemListingState.ViewState.Loading,
),
) {
init {
// TODO fetch real listing data in BIT-1057
viewModelScope.launch {
delay(2000)
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.NoItems,
)
}
}
}
override fun handleAction(action: VaultItemListingsAction) {
when (action) {
is VaultItemListingsAction.BackClick -> handleBackClick()
is VaultItemListingsAction.SearchIconClick -> handleSearchIconClick()
is VaultItemListingsAction.ItemClick -> handleItemClick(action)
is VaultItemListingsAction.AddVaultItemClick -> handleAddVaultItemClick()
is VaultItemListingsAction.RefreshClick -> handleRefreshClick()
}
}
//region VaultItemListing Handlers
private fun handleRefreshClick() {
// TODO implement refresh in BIT-1057
sendEvent(
event = VaultItemListingEvent.ShowToast(
text = "Not yet implemented".asText(),
),
)
}
private fun handleAddVaultItemClick() {
sendEvent(
event = VaultItemListingEvent.NavigateToAddVaultItem,
)
}
private fun handleItemClick(action: VaultItemListingsAction.ItemClick) {
sendEvent(
event = VaultItemListingEvent.NavigateToVaultItem(
id = action.id,
),
)
}
private fun handleBackClick() {
sendEvent(
event = VaultItemListingEvent.NavigateBack,
)
}
private fun handleSearchIconClick() {
sendEvent(
event = VaultItemListingEvent.NavigateToVaultSearchScreen,
)
}
//endregion VaultItemListing Handlers
}
/**
* Models state for the [VaultItemListingScreen].
*/
data class VaultItemListingState(
val itemListingType: ItemListingType,
val viewState: ViewState,
) {
/**
* Represents the specific view states for the [VaultItemListingScreen].
*/
sealed class ViewState {
/**
* Loading state for the [VaultItemListingScreen],
* signifying that the content is being processed.
*/
data object Loading : ViewState()
/**
* Represents a state where the [VaultItemListingScreen] has no items to display.
*/
data object NoItems : ViewState()
/**
* Content state for the [VaultItemListingScreen] showing the actual content or items.
*
* @property displayItemList List of items to display.
*/
data class Content(
val displayItemList: List<DisplayItem>,
) : ViewState()
/**
* Represents an error state for the [VaultItemListingScreen].
*
* @property message Error message to display.
*/
data class Error(
val message: Text,
) : ViewState()
}
/**
* An item to be displayed.
*
* @property id the id of the item.
* @property title title of the item.
* @property subtitle subtitle of the item.
* @property uri uri for the icon to be displayed (nullable).
* @property iconRes the icon to be displayed.
*/
data class DisplayItem(
val id: String,
val title: String,
val subtitle: String,
val uri: String?,
@DrawableRes
val iconRes: Int,
)
/**
* Represents different types of item listing.
*/
sealed class ItemListingType {
/**
* The title to display at the top of the screen.
*/
abstract val titleText: Text
/**
* Whether or not the screen has a floating action button (FAB).
*/
abstract val hasFab: Boolean
/**
* A Login item listing.
*/
data object Login : ItemListingType() {
override val titleText: Text
get() = R.string.logins.asText()
override val hasFab: Boolean
get() = true
}
/**
* A Card item listing.
*/
data object Card : ItemListingType() {
override val titleText: Text
get() = R.string.cards.asText()
override val hasFab: Boolean
get() = true
}
/**
* An Identity item listing.
*/
data object Identity : ItemListingType() {
override val titleText: Text
get() = R.string.identities.asText()
override val hasFab: Boolean
get() = true
}
/**
* A Secure Note item listing.
*/
data object SecureNote : ItemListingType() {
override val titleText: Text
get() = R.string.secure_notes.asText()
override val hasFab: Boolean
get() = true
}
/**
* A Secure Trash item listing.
*/
data object Trash : ItemListingType() {
override val titleText: Text
get() = R.string.trash.asText()
override val hasFab: Boolean
get() = false
}
/**
* A Folder item listing.
*
* @property folderId the id of the folder.
* @property folderName the name of the folder.
*/
data class Folder(
val folderId: String?,
// The folderName will always initially be an empty string
val folderName: String = "",
) : ItemListingType() {
override val titleText: Text
get() = folderName.asText()
override val hasFab: Boolean
get() = false
}
}
}
/**
* Models events for the [VaultItemListingScreen].
*/
sealed class VaultItemListingEvent {
/**
* Navigates to the Create Account screen.
*/
data object NavigateBack : VaultItemListingEvent()
/**
* Navigates to the VaultAddItemScreen.
*/
data object NavigateToAddVaultItem : VaultItemListingEvent()
/**
* Navigates to the VaultItemScreen.
*
* @property id the id of the item to navigate to.
*/
data class NavigateToVaultItem(val id: String) : VaultItemListingEvent()
/**
* Navigates to the VaultSearchScreen.
*/
data object NavigateToVaultSearchScreen : VaultItemListingEvent()
/**
* Show a toast with the given message.
*
* @property text the text to display.
*/
data class ShowToast(val text: Text) : VaultItemListingEvent()
}
/**
* Models actions for the [VaultItemListingScreen].
*/
sealed class VaultItemListingsAction {
/**
* Click the refresh button.
*/
data object RefreshClick : VaultItemListingsAction()
/**
* Click the back button.
*/
data object BackClick : VaultItemListingsAction()
/**
* Click the search icon.
*/
data object SearchIconClick : VaultItemListingsAction()
/**
* Click the add item button.
*/
data object AddVaultItemClick : VaultItemListingsAction()
/**
* Click on an item.
*
* @property id the id of the item that has been clicked.
*/
data class ItemClick(val id: String) : VaultItemListingsAction()
}

View file

@ -0,0 +1,20 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
/**
* Transforms a [VaultItemListingType] into a [VaultItemListingState.ItemListingType].
*/
fun VaultItemListingType.toItemListingType(): VaultItemListingState.ItemListingType =
when (this) {
is VaultItemListingType.Card -> VaultItemListingState.ItemListingType.Card
is VaultItemListingType.Folder -> {
VaultItemListingState.ItemListingType.Folder(folderId = folderId)
}
is VaultItemListingType.Identity -> VaultItemListingState.ItemListingType.Card
is VaultItemListingType.Login -> VaultItemListingState.ItemListingType.Login
is VaultItemListingType.SecureNote -> VaultItemListingState.ItemListingType.SecureNote
is VaultItemListingType.Trash -> VaultItemListingState.ItemListingType.Trash
}

View file

@ -0,0 +1,407 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
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.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.util.isProgressBar
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.update
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import com.x8bit.bitwarden.R
class VaultItemListingScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToVaultAddItemScreenCalled = false
private var onNavigateToVaultItemId: String? = null
private val mutableEventFlow = MutableSharedFlow<VaultItemListingEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<VaultItemListingViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
VaultItemListingScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToVaultItem = { onNavigateToVaultItemId = it },
onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true },
)
}
}
@Test
fun `NavigateBack event should invoke NavigateBack`() {
mutableEventFlow.tryEmit(VaultItemListingEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `clicking back button should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription(label = "Back")
.performClick()
verify { viewModel.trySendAction(VaultItemListingsAction.BackClick) }
}
@Test
fun `search icon click should send SearchIconClick action`() {
composeTestRule
.onNodeWithContentDescription("Search vault")
.performClick()
verify { viewModel.trySendAction(VaultItemListingsAction.SearchIconClick) }
}
@Test
fun `floating action button click should send AddItemClick action`() {
composeTestRule
.onNodeWithContentDescription("Add item")
.performClick()
verify { viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) }
}
@Test
fun `add an item button click should send AddItemClick action`() {
mutableStateFlow.update { it.copy(viewState = VaultItemListingState.ViewState.NoItems) }
composeTestRule
.onNodeWithText("Add an Item")
.performClick()
verify { viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) }
}
@Test
fun `refresh button click should send RefreshClick action`() {
mutableStateFlow.update {
it.copy(viewState = VaultItemListingState.ViewState.Error(message = "".asText()))
}
composeTestRule
.onNodeWithText("Try again")
.performClick()
verify { viewModel.trySendAction(VaultItemListingsAction.RefreshClick) }
}
@Test
fun `NavigateToAdd VaultItem event should call NavigateToVaultAddItemScreen`() {
mutableEventFlow.tryEmit(VaultItemListingEvent.NavigateToAddVaultItem)
assertTrue(onNavigateToVaultAddItemScreenCalled)
}
@Test
fun `NavigateToVaultItem event should call NavigateToVaultItemScreen`() {
val id = "id4321"
mutableEventFlow.tryEmit(VaultItemListingEvent.NavigateToVaultItem(id = id))
assertEquals(id, onNavigateToVaultItemId)
}
@Test
fun `progressbar should be displayed according to state`() {
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNode(isProgressBar)
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = VaultItemListingState.ViewState.NoItems)
}
composeTestRule
.onNode(isProgressBar)
.assertDoesNotExist()
}
@Test
fun `error text and retry should be displayed according to state`() {
val message = "error_message"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(message)
.assertIsNotDisplayed()
mutableStateFlow.update { it.copy(viewState = VaultItemListingState.ViewState.NoItems) }
composeTestRule
.onNodeWithText(message)
.assertIsNotDisplayed()
mutableStateFlow.update {
it.copy(viewState = VaultItemListingState.ViewState.Error(message.asText()))
}
composeTestRule
.onNodeWithText(message)
.assertIsDisplayed()
}
@Test
fun `Add an item button should be displayed according to state`() {
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = "Add an Item")
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = VaultItemListingState.ViewState.NoItems)
}
composeTestRule
.onNodeWithText(text = "Add an Item")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Trash)
}
composeTestRule
.onNodeWithText(text = "Add an Item")
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Folder(folderId = null))
}
composeTestRule
.onNodeWithText(text = "Add an Item")
.assertDoesNotExist()
}
@Test
fun `empty text should be displayed according to state`() {
mutableStateFlow.update {
DEFAULT_STATE.copy(viewState = VaultItemListingState.ViewState.NoItems)
}
composeTestRule
.onNodeWithText(text = "There are no items in your vault.")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Trash)
}
composeTestRule
.onNodeWithText(text = "There are no items in the trash.")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Folder(folderId = null))
}
composeTestRule
.onNodeWithText(text = "There are no items in this folder.")
.assertIsDisplayed()
}
@Test
fun `floating action button should be displayed according to state`() {
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithContentDescription("Add item")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Trash)
}
composeTestRule
.onNodeWithContentDescription("Add item")
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Folder(folderId = null))
}
composeTestRule
.onNodeWithContentDescription("Add item")
.assertDoesNotExist()
}
@Test
fun `Items text should be displayed according to state`() {
val items = "Items"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = items)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNode(hasScrollToNodeAction())
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed()
}
@Test
fun `Items text count should be displayed according to state`() {
val items = "Items"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = items)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNode(hasScrollToNodeAction())
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed()
.assertTextEquals(items, 1.toString())
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createDisplayItem(number = 1),
createDisplayItem(number = 2),
createDisplayItem(number = 3),
createDisplayItem(number = 4),
),
),
)
}
composeTestRule
.onNode(hasScrollToNodeAction())
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed()
.assertTextEquals(items, 4.toString())
}
@Test
fun `displayItems should be displayed according to state`() {
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNodeWithText(text = "mockTitle-1")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "mockSubtitle-1")
.assertIsDisplayed()
}
@Test
fun `clicking on a display item should send ItemClick action`() {
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNodeWithText(text = "mockTitle-1")
.assertIsDisplayed()
.performClick()
verify {
viewModel.trySendAction(VaultItemListingsAction.ItemClick("mockId-1"))
}
}
@Test
fun `topBar title should be displayed according to state`() {
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = "Logins")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.SecureNote)
}
composeTestRule
.onNodeWithText(text = "Secure notes")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Card)
}
composeTestRule
.onNodeWithText(text = "Cards")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Identity)
}
composeTestRule
.onNodeWithText(text = "Identities")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Trash)
}
composeTestRule
.onNodeWithText(text = "Trash")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(
itemListingType = VaultItemListingState.ItemListingType.Folder(
folderId = "mockId",
folderName = "mockName",
),
)
}
composeTestRule
.onNodeWithText(text = "mockName")
.assertIsDisplayed()
}
}
private val DEFAULT_STATE = VaultItemListingState(
itemListingType = VaultItemListingState.ItemListingType.Login,
viewState = VaultItemListingState.ViewState.Loading,
)
private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem =
VaultItemListingState.DisplayItem(
id = "mockId-$number",
title = "mockTitle-$number",
subtitle = "mockSubtitle-$number",
uri = "mockUri-$number",
iconRes = R.drawable.ic_card_item,
)

View file

@ -0,0 +1,120 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class VaultItemListingViewModelTest : BaseViewModelTest() {
private val initialState = createVaultItemListingState()
private val initialSavedStateHandle = createSavedStateHandleWithVaultItemListingType(
vaultItemListingType = VaultItemListingType.Login,
)
@Test
fun `initial state should be correct`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.stateFlow.test {
assertEquals(
initialState, awaitItem(),
)
}
}
@Test
fun `BackClick should emit NavigateBack`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.BackClick)
assertEquals(VaultItemListingEvent.NavigateBack, awaitItem())
}
}
@Test
fun `SearchIconClick should emit NavigateToVaultSearchScreen`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.SearchIconClick)
assertEquals(VaultItemListingEvent.NavigateToVaultSearchScreen, awaitItem())
}
}
@Test
fun `ItemClick should emit NavigateToVaultItem`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.ItemClick(id = "mock"))
assertEquals(VaultItemListingEvent.NavigateToVaultItem(id = "mock"), awaitItem())
}
}
@Test
fun `AddVaultItemClick should emit NavigateToAddVaultItem`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.AddVaultItemClick)
assertEquals(VaultItemListingEvent.NavigateToAddVaultItem, awaitItem())
}
}
@Test
fun `RefreshClick should emit ShowToast`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.RefreshClick)
assertEquals(
VaultItemListingEvent.ShowToast("Not yet implemented".asText()),
awaitItem(),
)
}
}
private fun createSavedStateHandleWithVaultItemListingType(
vaultItemListingType: VaultItemListingType,
) = SavedStateHandle().apply {
set(
"vault_item_listing_type",
when (vaultItemListingType) {
is VaultItemListingType.Card -> "card"
is VaultItemListingType.Folder -> "folder"
is VaultItemListingType.Identity -> "identity"
is VaultItemListingType.Login -> "login"
is VaultItemListingType.SecureNote -> "secure_note"
is VaultItemListingType.Trash -> "trash"
},
)
set(
"id",
when (vaultItemListingType) {
is VaultItemListingType.Card -> null
is VaultItemListingType.Folder -> vaultItemListingType.folderId
is VaultItemListingType.Identity -> null
is VaultItemListingType.Login -> null
is VaultItemListingType.SecureNote -> null
is VaultItemListingType.Trash -> null
},
)
}
private fun createVaultItemListingViewModel(
savedStateHandle: SavedStateHandle = initialSavedStateHandle,
): VaultItemListingViewModel =
VaultItemListingViewModel(
savedStateHandle = savedStateHandle,
)
@Suppress("MaxLineLength")
private fun createVaultItemListingState(
itemListingType: VaultItemListingState.ItemListingType = VaultItemListingState.ItemListingType.Login,
viewState: VaultItemListingState.ViewState = VaultItemListingState.ViewState.Loading,
): VaultItemListingState =
VaultItemListingState(
itemListingType = itemListingType,
viewState = viewState,
)
}

View file

@ -0,0 +1,28 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import org.junit.Assert.assertEquals
import org.junit.Test
class VaultItemListingTypeExtensionsTest {
@Suppress("MaxLineLength")
@Test
fun `toItemListingType should transform a VaultItemListingType into a VaultItemListingState ItemListingType`() {
val itemListingTypeList = listOf(
VaultItemListingType.Folder(folderId = "mock"),
VaultItemListingType.Trash,
)
val result = itemListingTypeList.map { it.toItemListingType() }
assertEquals(
listOf(
VaultItemListingState.ItemListingType.Folder(folderId = "mock"),
VaultItemListingState.ItemListingType.Trash,
),
result,
)
}
}