From f023650730edb05ea4aa0ef108cc835385c5e38c Mon Sep 17 00:00:00 2001 From: Oleg Semenenko <146032743+oleg-livefront@users.noreply.github.com> Date: Fri, 26 Jan 2024 17:09:07 -0600 Subject: [PATCH] BIT-458, BIT-459: Add screens for adding and editing folders (#795) --- .../components/BitwardenTextButton.kt | 13 +- .../components/BitwardenTwoButtonDialog.kt | 7 + .../settings/folders/FoldersNavigation.kt | 4 + .../feature/settings/folders/FoldersScreen.kt | 15 +- .../settings/folders/FoldersViewModel.kt | 6 +- .../addedit/FolderAddEditNavigation.kt | 80 ++++ .../folders/addedit/FolderAddEditScreen.kt | 186 ++++++++++ .../folders/addedit/FolderAddEditViewModel.kt | 299 +++++++++++++++ .../folders/model/FolderAddEditType.kt | 33 ++ .../vaultunlocked/VaultUnlockedNavigation.kt | 21 +- .../settings/folders/FoldersScreenTest.kt | 117 +++++- .../settings/folders/FoldersViewModelTest.kt | 126 ++++++- .../addedit/FolderAddEditScreenTest.kt | 220 +++++++++++ .../addedit/FolderAddEditViewModelTest.kt | 341 ++++++++++++++++++ 14 files changed, 1448 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/model/FolderAddEditType.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextButton.kt index bd1a094d6f..5d1ce5e805 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextButton.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextButton.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -16,6 +17,7 @@ import androidx.compose.ui.unit.dp * @param label The label for the button. * @param onClick The callback when the button is clicked. * @param modifier The [Modifier] to be applied to the button. + * @param labelTextColor The color for the label text. */ @Composable fun BitwardenTextButton( @@ -23,12 +25,21 @@ fun BitwardenTextButton( onClick: () -> Unit, modifier: Modifier = Modifier, isEnabled: Boolean = true, + labelTextColor: Color? = null, ) { + val defaultColors = if (labelTextColor != null) { + ButtonDefaults.textButtonColors( + contentColor = labelTextColor, + ) + } else { + ButtonDefaults.textButtonColors() + } + TextButton( onClick = onClick, modifier = modifier, enabled = isEnabled, - colors = ButtonDefaults.textButtonColors(), + colors = defaultColors, ) { Text( text = label, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTwoButtonDialog.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTwoButtonDialog.kt index 1963f78f1b..5361d73475 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTwoButtonDialog.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTwoButtonDialog.kt @@ -4,6 +4,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color /** * Represents a Bitwarden-styled dialog with two buttons. @@ -16,6 +17,8 @@ import androidx.compose.runtime.Composable * @param onDismissClick called when the dismiss button is clicked. * @param onDismissRequest called when the user attempts to dismiss the dialog (for example by * tapping outside of it). + * @param confirmTextColor The color of the confirm text. + * @param dismissTextColor The color of the dismiss text. */ @Composable fun BitwardenTwoButtonDialog( @@ -26,18 +29,22 @@ fun BitwardenTwoButtonDialog( onConfirmClick: () -> Unit, onDismissClick: () -> Unit, onDismissRequest: () -> Unit, + confirmTextColor: Color? = null, + dismissTextColor: Color? = null, ) { AlertDialog( onDismissRequest = onDismissRequest, dismissButton = { BitwardenTextButton( label = dismissButtonText, + labelTextColor = dismissTextColor, onClick = onDismissClick, ) }, confirmButton = { BitwardenTextButton( label = confirmButtonText, + labelTextColor = confirmTextColor, onClick = onConfirmClick, ) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersNavigation.kt index d0362d257c..fa0f0b5d42 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersNavigation.kt @@ -12,12 +12,16 @@ private const val FOLDERS_ROUTE = "settings_folders" */ fun NavGraphBuilder.foldersDestination( onNavigateBack: () -> Unit, + onNavigateToAddFolderScreen: () -> Unit, + onNavigateToEditFolderScreen: (folderId: String) -> Unit, ) { composableWithSlideTransitions( route = FOLDERS_ROUTE, ) { FoldersScreen( onNavigateBack = onNavigateBack, + onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToEditFolderScreen = onNavigateToEditFolderScreen, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreen.kt index a8e181ea6f..eab6f5e158 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreen.kt @@ -35,7 +35,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider -import com.x8bit.bitwarden.ui.platform.base.util.showNotYetImplementedToast import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold @@ -50,6 +49,8 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderDisp @Composable fun FoldersScreen( onNavigateBack: () -> Unit, + onNavigateToAddFolderScreen: () -> Unit, + onNavigateToEditFolderScreen: (folderId: String) -> Unit, viewModel: FoldersViewModel = hiltViewModel(), ) { val state = viewModel.stateFlow.collectAsStateWithLifecycle() @@ -57,13 +58,9 @@ fun FoldersScreen( EventsEffect(viewModel = viewModel) { event -> when (event) { is FoldersEvent.NavigateBack -> onNavigateBack() - is FoldersEvent.NavigateToAddFolderScreen -> { - showNotYetImplementedToast(context = context) - } - - is FoldersEvent.NavigateToEditFolderScreen -> { - showNotYetImplementedToast(context = context) - } + is FoldersEvent.NavigateToAddFolderScreen -> onNavigateToAddFolderScreen() + is FoldersEvent.NavigateToEditFolderScreen -> + onNavigateToEditFolderScreen(event.folderId) is FoldersEvent.ShowToast -> { Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() @@ -108,7 +105,7 @@ fun FoldersScreen( FoldersContent( foldersList = viewState.folderList, onItemClick = remember(viewModel) { - { viewModel.trySendAction(FoldersAction.OnFolderClick(it)) } + { viewModel.trySendAction(FoldersAction.FolderClick(it)) } }, modifier = Modifier .padding(innerPadding) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModel.kt index 1e8e86dbe8..7cd030dcf0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModel.kt @@ -39,10 +39,10 @@ class FoldersViewModel @Inject constructor( is FoldersAction.AddFolderButtonClick -> handleAddFolderButtonClicked() is FoldersAction.CloseButtonClick -> handleCloseButtonClicked() is FoldersAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) - is FoldersAction.OnFolderClick -> handleFolderClick(action) + is FoldersAction.FolderClick -> handleFolderClick(action) } - private fun handleFolderClick(action: FoldersAction.OnFolderClick) { + private fun handleFolderClick(action: FoldersAction.FolderClick) { sendEvent(FoldersEvent.NavigateToEditFolderScreen(action.folderId)) } @@ -196,7 +196,7 @@ sealed class FoldersAction { /** * Indicates that the user clicked a folder. */ - data class OnFolderClick(val folderId: String) : FoldersAction() + data class FolderClick(val folderId: String) : FoldersAction() /** * Indicates that the user clicked the close button. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditNavigation.kt new file mode 100644 index 0000000000..369b9de617 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditNavigation.kt @@ -0,0 +1,80 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions +import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderAddEditType + +private const val ADD_TYPE: String = "add" +private const val EDIT_TYPE: String = "edit" +private const val EDIT_ITEM_ID: String = "folder_edit_id" + +private const val ADD_EDIT_ITEM_PREFIX: String = "folder_add_edit_item" +private const val ADD_EDIT_ITEM_TYPE: String = "folder_add_edit_type" + +private const val ADD_EDIT_ITEM_ROUTE: String = + "$ADD_EDIT_ITEM_PREFIX/{$ADD_EDIT_ITEM_TYPE}?$EDIT_ITEM_ID={$EDIT_ITEM_ID}" + +/** + * Class to retrieve folder add & edit arguments from the [SavedStateHandle]. + */ +@OmitFromCoverage +data class FolderAddEditArgs( + val folderAddEditType: FolderAddEditType, +) { + constructor(savedStateHandle: SavedStateHandle) : this( + folderAddEditType = when (requireNotNull(savedStateHandle[ADD_EDIT_ITEM_TYPE])) { + ADD_TYPE -> FolderAddEditType.AddItem + EDIT_TYPE -> FolderAddEditType.EditItem(requireNotNull(savedStateHandle[EDIT_ITEM_ID])) + else -> throw IllegalStateException("Unknown FolderAddEditType.") + }, + ) +} + +/** + * Add the folder add & edit screen to the nav graph. + */ +@Suppress("LongParameterList") +fun NavGraphBuilder.folderAddEditDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions( + route = ADD_EDIT_ITEM_ROUTE, + arguments = listOf( + navArgument(ADD_EDIT_ITEM_TYPE) { type = NavType.StringType }, + ), + ) { + FolderAddEditScreen(onNavigateBack = onNavigateBack) + } +} + +/** + * Navigate to the folder add & edit screen. + */ +fun NavController.navigateToFolderAddEdit( + folderAddEditType: FolderAddEditType, + navOptions: NavOptions? = null, +) { + navigate( + route = "$ADD_EDIT_ITEM_PREFIX/${folderAddEditType.toTypeString()}" + + "?$EDIT_ITEM_ID=${folderAddEditType.toIdOrNull()}", + navOptions = navOptions, + ) +} + +private fun FolderAddEditType.toTypeString(): String = + when (this) { + is FolderAddEditType.AddItem -> ADD_TYPE + is FolderAddEditType.EditItem -> EDIT_TYPE + } + +private fun FolderAddEditType.toIdOrNull(): String? = + when (this) { + is FolderAddEditType.AddItem -> null + is FolderAddEditType.EditItem -> folderId + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreen.kt new file mode 100644 index 0000000000..272d35d0c0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreen.kt @@ -0,0 +1,186 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +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 +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.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BasicDialogState +import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent +import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent +import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem +import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField +import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog +import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState +import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData +import kotlinx.collections.immutable.persistentListOf + +/** + * Displays the screen for adding or editing a folder item. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") +@Composable +fun FolderAddEditScreen( + onNavigateBack: () -> Unit, + viewModel: FolderAddEditViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + + var shouldShowConfirmationDialog by rememberSaveable { mutableStateOf(false) } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is FolderAddEditEvent.NavigateBack -> onNavigateBack.invoke() + is FolderAddEditEvent.ShowToast -> { + Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show() + } + } + } + + FolderAddEditItemDialogs( + dialogState = state.dialog, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(FolderAddEditAction.DismissDialog) } + }, + ) + + if (shouldShowConfirmationDialog) { + BitwardenTwoButtonDialog( + title = null, + message = stringResource(id = R.string.do_you_really_want_to_delete), + dismissButtonText = stringResource(id = R.string.cancel), + confirmButtonText = stringResource(id = R.string.delete), + onDismissClick = { shouldShowConfirmationDialog = false }, + onConfirmClick = { + shouldShowConfirmationDialog = false + viewModel.trySendAction(FolderAddEditAction.DeleteClick) + }, + onDismissRequest = { shouldShowConfirmationDialog = false }, + confirmTextColor = MaterialTheme.colorScheme.error, + ) + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = state.screenDisplayName.invoke(), + scrollBehavior = scrollBehavior, + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(FolderAddEditAction.CloseClick) } + }, + actions = { + BitwardenTextButton( + label = stringResource(id = R.string.save), + onClick = remember(viewModel) { + { viewModel.trySendAction(FolderAddEditAction.SaveClick) } + }, + ) + BitwardenOverflowActionItem( + menuItemDataList = persistentListOf( + OverflowMenuItemData( + text = stringResource(id = R.string.delete), + onClick = { shouldShowConfirmationDialog = true }, + ), + ), + ) + }, + ) + }, + ) { innerPadding -> + when (val viewState = state.viewState) { + is FolderAddEditState.ViewState.Content -> { + Column( + Modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + BitwardenTextField( + label = stringResource(id = R.string.name), + value = viewState.folderName, + onValueChange = remember(viewModel) { + { viewModel.trySendAction(FolderAddEditAction.NameTextChange(it)) } + }, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + ) + } + } + + is FolderAddEditState.ViewState.Error -> { + BitwardenErrorContent( + message = viewState.message(), + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } + + is FolderAddEditState.ViewState.Loading -> { + BitwardenLoadingContent( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } + } + } +} + +@Composable +private fun FolderAddEditItemDialogs( + dialogState: FolderAddEditState.DialogState?, + onDismissRequest: () -> Unit, +) { + when (dialogState) { + is FolderAddEditState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(dialogState.label), + ) + } + + is FolderAddEditState.DialogState.Error -> BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = dialogState.message, + ), + onDismissRequest = onDismissRequest, + ) + + null -> Unit + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt new file mode 100644 index 0000000000..0f511839f2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt @@ -0,0 +1,299 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.core.FolderView +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +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.feature.settings.folders.model.FolderAddEditType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * Handles [FolderAddEditAction], + * and launches [FolderAddEditEvent] for the [FolderAddEditScreen]. + */ +@HiltViewModel +@Suppress("TooManyFunctions", "LargeClass") +class FolderAddEditViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val vaultRepository: VaultRepository, +) : BaseViewModel( + // We load the state from the savedStateHandle for testing purposes. + initialState = savedStateHandle[KEY_STATE] + ?: run { + val folderAddEditType = FolderAddEditArgs(savedStateHandle).folderAddEditType + FolderAddEditState( + folderAddEditType = folderAddEditType, + viewState = when (folderAddEditType) { + is FolderAddEditType.AddItem -> FolderAddEditState.ViewState.Content("") + is FolderAddEditType.EditItem -> FolderAddEditState.ViewState.Loading + }, + dialog = null, + ) + }, +) { + init { + state + .folderAddEditType + .folderId + ?.let { folderId -> + vaultRepository + .getVaultFolderStateFlow(folderId) + .onEach { sendAction(FolderAddEditAction.Internal.VaultDataReceive(it)) } + .launchIn(viewModelScope) + } + } + + override fun handleAction(action: FolderAddEditAction) { + when (action) { + is FolderAddEditAction.CloseClick -> handleCloseClick() + is FolderAddEditAction.DeleteClick -> handleDeleteClick() + is FolderAddEditAction.DismissDialog -> handleDismissDialog() + is FolderAddEditAction.NameTextChange -> handleNameTextChange(action) + is FolderAddEditAction.SaveClick -> handleSaveClick() + is FolderAddEditAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) + } + } + + private fun handleCloseClick() { + sendEvent(FolderAddEditEvent.NavigateBack) + } + + private fun handleSaveClick() { + sendEvent(FolderAddEditEvent.ShowToast("Not yet implemented.".asText())) + } + + private fun handleDeleteClick() { + sendEvent(FolderAddEditEvent.ShowToast("Not yet implemented.".asText())) + } + + private fun handleDismissDialog() { + mutableStateFlow.update { it.copy(dialog = null) } + } + + private fun handleNameTextChange(action: FolderAddEditAction.NameTextChange) { + mutableStateFlow.update { + it.copy( + viewState = FolderAddEditState.ViewState.Content( + folderName = action.name, + ), + ) + } + } + + @Suppress("LongMethod") + private fun handleVaultDataReceive(action: FolderAddEditAction.Internal.VaultDataReceive) { + when (val vaultDataState = action.vaultDataState) { + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + viewState = FolderAddEditState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + is DataState.Loaded -> { + mutableStateFlow.update { + it.copy( + viewState = vaultDataState + .data + ?.let { folder -> + FolderAddEditState.ViewState.Content( + folderName = folder.name, + ) + } + ?: FolderAddEditState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + is DataState.Loading -> { + mutableStateFlow.update { + it.copy(viewState = FolderAddEditState.ViewState.Loading) + } + } + + is DataState.NoNetwork -> { + mutableStateFlow.update { + it.copy( + viewState = FolderAddEditState.ViewState.Error( + message = R.string.internet_connection_required_title + .asText() + .concat(R.string.internet_connection_required_message.asText()), + ), + ) + } + } + + is DataState.Pending -> { + mutableStateFlow.update { + it.copy( + viewState = vaultDataState + .data + ?.let { folder -> + FolderAddEditState.ViewState.Content( + folderName = folder.name, + ) + } + ?: FolderAddEditState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + } + } +} + +/** + * Represents the state for adding or editing a folder. + * + * @property folderAddEditType Indicates whether the VM is in add or edit mode. + * @property viewState indicates what view state the screen is in. + * @property dialog the state for the dialogs that can be displayed. + */ +@Parcelize +data class FolderAddEditState( + val folderAddEditType: FolderAddEditType, + val viewState: ViewState, + val dialog: DialogState?, +) : Parcelable { + + /** + * Helper to determine the screen display name. + */ + val screenDisplayName: Text + get() = when (folderAddEditType) { + FolderAddEditType.AddItem -> R.string.add_item.asText() + is FolderAddEditType.EditItem -> R.string.edit_item.asText() + } + + /** + * Represents the specific view states for the [FolderAddEditScreen] + */ + sealed class ViewState : Parcelable { + /** + * Represents an error state for the [FolderAddEditScreen]. + */ + @Parcelize + data class Error( + val message: Text, + ) : ViewState() + + /** + * Loading state for the [FolderAddEditScreen], signifying that the content is being + * processed. + */ + @Parcelize + data object Loading : ViewState() + + /** + * Represents a loaded content state for the [FolderAddEditScreen]. + */ + @Parcelize + data class Content( + val folderName: String, + ) : ViewState() + } + + /** + * Displays a dialog. + */ + @Parcelize + sealed class DialogState : Parcelable { + + /** + * Displays a loading dialog to the user. + */ + @Parcelize + data class Loading(val label: Text) : DialogState() + + /** + * Displays an error dialog to the user. + */ + @Parcelize + data class Error( + val message: Text, + ) : DialogState() + } +} + +/** + * Represents a set of events that can be emitted during + * the process of adding or editing a folder. + */ +sealed class FolderAddEditEvent { + + /** + * Navigate back to previous screen. + */ + data object NavigateBack : FolderAddEditEvent() + + /** + * Shows a toast with the given [message]. + */ + data class ShowToast(val message: Text) : FolderAddEditEvent() +} + +/** + * Represents a set of actions related to the process of adding or editing a folder. + */ +sealed class FolderAddEditAction { + + /** + * User clicked close. + */ + data object CloseClick : FolderAddEditAction() + + /** + * The user has clicked to delete the folder. + */ + data object DeleteClick : FolderAddEditAction() + + /** + * The user has clicked to dismiss the dialog. + */ + data object DismissDialog : FolderAddEditAction() + + /** + * Fired when the name text input is changed. + * + * @property name The name of the folder. + */ + data class NameTextChange(val name: String) : FolderAddEditAction() + + /** + * Represents the action when the save button is clicked. + */ + data object SaveClick : FolderAddEditAction() + + /** + * Actions for internal use by the ViewModel. + */ + sealed class Internal : FolderAddEditAction() { + + /** + * Indicates that the vault items data has been received. + */ + data class VaultDataReceive( + val vaultDataState: DataState, + ) : Internal() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/model/FolderAddEditType.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/model/FolderAddEditType.kt new file mode 100644 index 0000000000..191a8e4cb7 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/model/FolderAddEditType.kt @@ -0,0 +1,33 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.folders.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Represents the difference between creating a + * completely new folder and editing an existing one. + */ +sealed class FolderAddEditType : Parcelable { + + /** + * The ID of the folder (nullable). + */ + abstract val folderId: String? + + /** + * Indicates that we want to create a completely new folder. + */ + @Parcelize + data object AddItem : FolderAddEditType() { + override val folderId: String? + get() = null + } + + /** + * Indicates that we want to edit an existing folder. + */ + @Parcelize + data class EditItem( + override val folderId: String, + ) : FolderAddEditType() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 06cfbed8ce..723b08e712 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -11,9 +11,12 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteac import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.navigateToPendingRequests import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.pendingRequestsDestination import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.exportVaultDestination -import com.x8bit.bitwarden.ui.platform.feature.settings.folders.foldersDestination -import com.x8bit.bitwarden.ui.platform.feature.settings.folders.navigateToFolders import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.navigateToExportVault +import com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit.folderAddEditDestination +import com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit.navigateToFolderAddEdit +import com.x8bit.bitwarden.ui.platform.feature.settings.folders.foldersDestination +import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderAddEditType +import com.x8bit.bitwarden.ui.platform.feature.settings.folders.navigateToFolders import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination import com.x8bit.bitwarden.ui.tools.feature.generator.generatorModalDestination @@ -128,7 +131,19 @@ fun NavGraphBuilder.vaultUnlockedGraph( addSendDestination(onNavigateBack = { navController.popBackStack() }) passwordHistoryDestination(onNavigateBack = { navController.popBackStack() }) exportVaultDestination(onNavigateBack = { navController.popBackStack() }) - foldersDestination(onNavigateBack = { navController.popBackStack() }) + foldersDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToAddFolderScreen = { + navController.navigateToFolderAddEdit(FolderAddEditType.AddItem) + }, + onNavigateToEditFolderScreen = { + navController.navigateToFolderAddEdit( + FolderAddEditType.EditItem(it), + ) + }, + ) + + folderAddEditDestination(onNavigateBack = { navController.popBackStack() }) generatorModalDestination(onNavigateBack = { navController.popBackStack() }) searchDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreenTest.kt index b32083f367..3b1accf805 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreenTest.kt @@ -1,13 +1,21 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.folders +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderDisplayItem import io.mockk.every import io.mockk.mockk import io.mockk.verify 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 @@ -15,6 +23,8 @@ import org.junit.Test class FoldersScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false + private var onNavigateToEditFolderScreenId: String? = null + private var onNavigateToAddFolderScreenCalled = false private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) @@ -28,11 +38,33 @@ class FoldersScreenTest : BaseComposeTest() { composeTestRule.setContent { FoldersScreen( viewModel = viewModel, + onNavigateToEditFolderScreen = { onNavigateToEditFolderScreenId = it }, + onNavigateToAddFolderScreen = { onNavigateToAddFolderScreenCalled = true }, onNavigateBack = { onNavigateBackCalled = true }, ) } } + @Test + fun `NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(FoldersEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `NavigateToAddFolderScreen should call onNavigateToAddFolderScreen`() { + mutableEventFlow.tryEmit(FoldersEvent.NavigateToAddFolderScreen) + assertTrue(onNavigateToAddFolderScreenCalled) + } + + @Test + fun `NavigateToEditFolderScreen should call onNavigateToEditFolderScreen`() { + val tesId = "TestId" + + mutableEventFlow.tryEmit(FoldersEvent.NavigateToEditFolderScreen(tesId)) + assertEquals(tesId, onNavigateToEditFolderScreenId) + } + @Test fun `close button click should send CloseButtonClick`() { composeTestRule.onNodeWithContentDescription("Close").performClick() @@ -48,11 +80,90 @@ class FoldersScreenTest : BaseComposeTest() { } @Test - fun `NavigateBack should call onNavigateBack`() { - mutableEventFlow.tryEmit(FoldersEvent.NavigateBack) - assertTrue(onNavigateBackCalled) + fun `error text should display according to state`() { + val message = "An error has occurred" + + mutableStateFlow.update { DEFAULT_LOADED_STATE } + + composeTestRule + .onNodeWithText(message) + .assertIsNotDisplayed() + + mutableStateFlow.update { + FoldersState( + viewState = FoldersState.ViewState.Error( + message = "An error has occurred".asText(), + ), + ) + } + + composeTestRule + .onNodeWithText(message) + .assertIsDisplayed() + } + + @Test + fun `folders should be displayed according to state`() { + mutableStateFlow.update { + it.copy( + viewState = FoldersState.ViewState.Content(emptyList()), + ) + } + + composeTestRule + .onNodeWithText("There are no folders to list.") + .isNotDisplayed() + + mutableStateFlow.update { DEFAULT_LOADED_STATE } + composeTestRule + .onNodeWithText(text = "Test Folder 1") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(text = "Test Folder 2") + .assertIsDisplayed() + + mutableStateFlow.update { DEFAULT_STATE } + + composeTestRule + .onNodeWithText(text = "Test Folder 1") + .assertDoesNotExist() + + composeTestRule + .onNodeWithText(text = "Test Folder 2") + .assertDoesNotExist() + } + + @Test + fun `clicking on a folder should send FolderClick action`() { + mutableStateFlow.update { DEFAULT_LOADED_STATE } + + composeTestRule + .onNodeWithText(text = "Test Folder 1") + .assertIsDisplayed() + .performClick() + + verify { + viewModel.trySendAction(FoldersAction.FolderClick("Id")) + } } } private val DEFAULT_STATE = FoldersState(viewState = FoldersState.ViewState.Loading) + +private val DEFAULT_LOADED_STATE = + FoldersState( + viewState = FoldersState.ViewState.Content( + folderList = listOf( + FolderDisplayItem( + id = "Id", + name = "Test Folder 1", + ), + FolderDisplayItem( + id = "Id 2", + name = "Test Folder 2", + ), + ), + ), + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModelTest.kt index 401a258a27..f3510c90f2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModelTest.kt @@ -1,10 +1,15 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.folders import app.cash.turbine.test +import com.bitwarden.core.DateTime import com.bitwarden.core.FolderView +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.base.util.concat +import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderDisplayItem import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow @@ -14,7 +19,8 @@ import org.junit.jupiter.api.Test class FoldersViewModelTest : BaseViewModelTest() { - private val mutableFoldersStateFlow = MutableStateFlow(DataState.Loaded(listOf())) + private val mutableFoldersStateFlow = + MutableStateFlow>>(DataState.Loaded(listOf())) private val vaultRepository: VaultRepository = mockk { every { foldersStateFlow } returns mutableFoldersStateFlow @@ -41,7 +47,125 @@ class FoldersViewModelTest : BaseViewModelTest() { } } + @Test + fun `FolderClick should emit NavigateToAddFolderScreen`() = runTest { + val testId = "TestId" + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(FoldersAction.FolderClick(testId)) + assertEquals( + FoldersEvent.NavigateToEditFolderScreen(testId), + awaitItem(), + ) + } + } + + @Test + fun `folderStateFlow Error should update state to error`() { + val viewModel = createViewModel() + + mutableFoldersStateFlow.tryEmit( + value = DataState.Error( + data = listOf(), + error = IllegalStateException(), + ), + ) + + assertEquals( + createFolderState( + viewState = FoldersState.ViewState.Error( + R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `folderStateFlow Loaded with data should update state to content`() { + val viewModel = createViewModel() + + mutableFoldersStateFlow.tryEmit( + DataState.Loaded( + listOf(DEFAULT_FOLDER_VIEW), + ), + ) + assertEquals( + createFolderState( + viewState = FoldersState.ViewState.Content( + folderList = listOf(DEFAULT__DISPLAY_FOLDER), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `folderStateFlow Loading should update the state to Loading`() { + val viewModel = createViewModel() + + mutableFoldersStateFlow.tryEmit( + DataState.Loading, + ) + + assertEquals( + createFolderState(), + viewModel.stateFlow.value, + ) + } + + @Test + fun `folderStateFlow NoNetwork should update the state to Error`() { + val viewModel = createViewModel() + + mutableFoldersStateFlow.tryEmit( + value = DataState.NoNetwork( + data = listOf(), + ), + ) + + assertEquals( + createFolderState( + viewState = FoldersState.ViewState.Error( + R.string.internet_connection_required_title + .asText() + .concat(R.string.internet_connection_required_message.asText()), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `FolderStateFlow Pending should update the state to Content`() { + val viewModel = createViewModel() + + mutableFoldersStateFlow.tryEmit( + value = DataState.Pending( + listOf(DEFAULT_FOLDER_VIEW), + ), + ) + + assertEquals( + createFolderState( + viewState = FoldersState.ViewState.Content( + folderList = listOf(DEFAULT__DISPLAY_FOLDER), + ), + ), + viewModel.stateFlow.value, + ) + } + private fun createViewModel(): FoldersViewModel = FoldersViewModel( vaultRepository = vaultRepository, ) + + private fun createFolderState( + viewState: FoldersState.ViewState = FoldersState.ViewState.Loading, + ) = FoldersState( + viewState = viewState, + ) } + +private val DEFAULT_FOLDER_VIEW = FolderView("1", "test", revisionDate = DateTime.now()) +private val DEFAULT__DISPLAY_FOLDER = FolderDisplayItem("1", "test") diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreenTest.kt new file mode 100644 index 0000000000..fc3102000e --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreenTest.kt @@ -0,0 +1,220 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit + +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderAddEditType +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertTrue + +class FolderAddEditScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled = false + + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE_ADD) + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setup() { + composeTestRule.setContent { + FolderAddEditScreen( + viewModel = viewModel, + onNavigateBack = { onNavigateBackCalled = true }, + ) + } + } + + @Test + fun `NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(FolderAddEditEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `clicking save button should send SaveClick action`() { + composeTestRule + .onNodeWithText("Save") + .performClick() + + verify { + viewModel.trySendAction( + FolderAddEditAction.SaveClick, + ) + } + } + + @Test + fun `clicking overflow menu and delete, and cancel should dismiss the dialog`() { + val deleteText = "Do you really want to delete? This cannot be undone." + + // Open the overflow menu + composeTestRule + .onNodeWithContentDescription("More") + .performClick() + // Click on the delete item in the dropdown + composeTestRule + .onAllNodesWithText("Delete") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + composeTestRule + .onNodeWithText(deleteText) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Cancel") + .performClick() + + composeTestRule + .onNodeWithText(deleteText) + .assertIsNotDisplayed() + } + + @Suppress("MaxLineLength") + @Test + fun `clicking overflow menu and delete, and delete confirmation again should send a DeleteClick Action`() { + val deleteText = "Do you really want to delete? This cannot be undone." + + composeTestRule + .onNodeWithContentDescription("More") + .performClick() + + composeTestRule + .onNodeWithText("Delete") + .performClick() + + composeTestRule + .onNodeWithText(deleteText) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Delete") + .performClick() + + composeTestRule + .onNodeWithText(deleteText) + .assertIsNotDisplayed() + + verify { + viewModel.trySendAction( + FolderAddEditAction.DeleteClick, + ) + } + } + + @Test + fun `error text should display according to state`() { + val message = "An error has occurred" + + mutableStateFlow.update { DEFAULT_STATE_ADD } + + composeTestRule + .onNodeWithText(message) + .assertIsNotDisplayed() + + mutableStateFlow.update { + DEFAULT_STATE_ADD.copy( + viewState = FolderAddEditState.ViewState.Error(message.asText()), + ) + } + + composeTestRule + .onNodeWithText(message) + .assertIsDisplayed() + } + + @Test + fun `error dialog should display according to state`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialog = FolderAddEditState.DialogState.Error( + message = "Error Message".asText(), + ), + ) + } + + composeTestRule + .onNodeWithText("An error has occurred.") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Error Message") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule.onNode(isDialog()).assertIsDisplayed() + } + + @Test + fun `loading dialog should display according to state`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialog = FolderAddEditState.DialogState.Loading( + label = "Loading".asText(), + ), + ) + } + + composeTestRule + .onNodeWithText("Loading") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule.onNode(isDialog()).assertIsDisplayed() + } + + @Test + fun `content should be displayed according to the state`() { + composeTestRule + .onNodeWithText("TestName") + .assertIsNotDisplayed() + + mutableStateFlow.update { + DEFAULT_STATE_EDIT.copy( + viewState = FolderAddEditState.ViewState.Content( + folderName = "TestName", + ), + ) + } + + composeTestRule + .onNodeWithText("TestName") + .assertIsDisplayed() + } +} + +private val DEFAULT_STATE_ADD = FolderAddEditState( + folderAddEditType = FolderAddEditType.AddItem, + viewState = FolderAddEditState.ViewState.Loading, + dialog = null, +) +private val DEFAULT_STATE_EDIT = FolderAddEditState( + folderAddEditType = FolderAddEditType.EditItem("1"), + viewState = FolderAddEditState.ViewState.Loading, + dialog = null, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt new file mode 100644 index 0000000000..0e77b2ea45 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt @@ -0,0 +1,341 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.core.DateTime +import com.bitwarden.core.FolderView +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.base.util.concat +import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderAddEditType +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class FolderAddEditViewModelTest : BaseViewModelTest() { + + private val mutableFoldersStateFlow = + MutableStateFlow>(DataState.Loading) + + private val vaultRepository: VaultRepository = mockk { + every { getVaultFolderStateFlow(DEFAULT_EDIT_ITEM_ID) } returns mutableFoldersStateFlow + } + + @Test + fun `initial add state should be correct`() = runTest { + val folderAddEditType = FolderAddEditType.AddItem + + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = DEFAULT_STATE.copy( + folderAddEditType = folderAddEditType, + ), + ), + ) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + verify(exactly = 0) { + vaultRepository.getVaultItemStateFlow(DEFAULT_EDIT_ITEM_ID) + } + } + + @Test + fun `initial edit state should be correct`() = runTest { + val folderAddEditType = FolderAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) + + val initState = DEFAULT_STATE.copy( + folderAddEditType = folderAddEditType, + ) + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = DEFAULT_STATE.copy( + folderAddEditType = folderAddEditType, + ), + ), + ) + assertEquals( + initState.copy(viewState = FolderAddEditState.ViewState.Loading), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { + vaultRepository.getVaultFolderStateFlow(DEFAULT_EDIT_ITEM_ID) + } + } + + @Test + fun `CloseClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(FolderAddEditAction.CloseClick) + assertEquals(FolderAddEditEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `DeleteClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(FolderAddEditAction.DeleteClick) + assertEquals(FolderAddEditEvent.ShowToast("Not yet implemented.".asText()), awaitItem()) + } + } + + @Test + fun `DismissDialog should emit update dialog state to null`() = runTest { + val viewModel = createViewModel() + + viewModel.trySendAction(FolderAddEditAction.DismissDialog) + + val expectedState = DEFAULT_STATE.copy( + dialog = null, + ) + + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + @Test + fun `NameTextChange should update name`() = runTest { + val viewModel = createViewModel() + + val expectedState = DEFAULT_STATE.copy( + viewState = FolderAddEditState.ViewState.Content( + folderName = "NewName", + ), + ) + + viewModel.trySendAction(FolderAddEditAction.NameTextChange("NewName")) + + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + @Test + fun `SaveClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(FolderAddEditAction.SaveClick) + assertEquals(FolderAddEditEvent.ShowToast("Not yet implemented.".asText()), awaitItem()) + } + } + + @Test + fun `folderStateFlow Error should update state to error`() { + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem((DEFAULT_EDIT_ITEM_ID)), + ), + ), + ) + + mutableFoldersStateFlow.tryEmit( + value = DataState.Error( + data = null, + error = IllegalStateException(), + ), + ) + + assertEquals( + DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID), + viewState = FolderAddEditState.ViewState.Error( + R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `folderStateFlow Loaded with data should update state to content`() { + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem((DEFAULT_EDIT_ITEM_ID)), + ), + ), + ) + + mutableFoldersStateFlow.tryEmit( + DataState.Loaded( + FolderView( + DEFAULT_EDIT_ITEM_ID, + DEFAULT_FOLDER_NAME, + revisionDate = DateTime.now(), + ), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID), + viewState = FolderAddEditState.ViewState.Content( + folderName = DEFAULT_FOLDER_NAME, + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `folderStateFlow Loaded with empty data should update state to error`() { + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem((DEFAULT_EDIT_ITEM_ID)), + ), + ), + ) + + mutableFoldersStateFlow.tryEmit(DataState.Loaded(null)) + assertEquals( + DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID), + viewState = FolderAddEditState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `folderStateFlow Loading should update the state to Loading`() { + val viewModel = createViewModel() + + mutableFoldersStateFlow.tryEmit( + DataState.Loading, + ) + + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + + @Test + fun `folderStateFlow NoNetwork should update the state to Error`() { + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem((DEFAULT_EDIT_ITEM_ID)), + ), + ), + ) + + mutableFoldersStateFlow.tryEmit( + value = DataState.NoNetwork( + data = null, + ), + ) + + assertEquals( + DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID), + viewState = FolderAddEditState.ViewState.Error( + R.string.internet_connection_required_title + .asText() + .concat(R.string.internet_connection_required_message.asText()), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `folderStateFlow Pending should update the state to Content`() { + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem((DEFAULT_EDIT_ITEM_ID)), + ), + ), + ) + + mutableFoldersStateFlow.tryEmit( + value = DataState.Pending( + FolderView( + DEFAULT_EDIT_ITEM_ID, + DEFAULT_FOLDER_NAME, + revisionDate = DateTime.now(), + ), + ), + ) + + assertEquals( + DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID), + viewState = FolderAddEditState.ViewState.Content( + folderName = DEFAULT_FOLDER_NAME, + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `folderStateFlow Pending with empty data should update state to error`() { + val viewModel = createViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem((DEFAULT_EDIT_ITEM_ID)), + ), + ), + ) + + mutableFoldersStateFlow.tryEmit(DataState.Pending(null)) + assertEquals( + DEFAULT_STATE.copy( + folderAddEditType = FolderAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID), + viewState = FolderAddEditState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + private fun createSavedStateHandleWithState( + state: FolderAddEditState? = DEFAULT_STATE, + ) = SavedStateHandle().apply { + val folderAddEditType = state?.folderAddEditType + ?: FolderAddEditType.AddItem + + set("state", state) + set( + "folder_add_edit_type", + when (folderAddEditType) { + FolderAddEditType.AddItem -> "add" + is FolderAddEditType.EditItem -> "edit" + }, + ) + set("folder_edit_id", (folderAddEditType as? FolderAddEditType.EditItem)?.folderId) + } + + private fun createViewModel( + savedStateHandle: SavedStateHandle = createSavedStateHandleWithState(), + ): FolderAddEditViewModel = FolderAddEditViewModel( + savedStateHandle = savedStateHandle, + vaultRepository = vaultRepository, + ) +} + +private val DEFAULT_STATE = FolderAddEditState( + viewState = FolderAddEditState.ViewState.Loading, + dialog = FolderAddEditState.DialogState.Loading("Loading".asText()), + folderAddEditType = FolderAddEditType.AddItem, +) + +private const val DEFAULT_EDIT_ITEM_ID = "edit_id" +private const val DEFAULT_FOLDER_NAME = "test_name"