diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt new file mode 100644 index 000000000..ee4d61c4f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingEmpty.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingEmpty.kt new file mode 100644 index 000000000..7c3fc7aeb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingEmpty.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingError.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingError.kt new file mode 100644 index 000000000..39bcb88b0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingError.kt @@ -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()) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingLoading.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingLoading.kt new file mode 100644 index 000000000..503c7e999 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingLoading.kt @@ -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()) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index 37847f711..d5d337ab7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -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) + } + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt new file mode 100644 index 000000000..a72456e23 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensions.kt new file mode 100644 index 000000000..ab1b1037a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensions.kt @@ -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 + } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt new file mode 100644 index 000000000..d650e9382 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -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, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt new file mode 100644 index 000000000..00616ad62 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -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, + ) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensionsTest.kt new file mode 100644 index 000000000..8da4362ca --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensionsTest.kt @@ -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, + ) + } +}