BIT-458, BIT-459: Add screens for adding and editing folders (#795)

This commit is contained in:
Oleg Semenenko 2024-01-26 17:09:07 -06:00 committed by Álison Fernandes
parent 9338a51d68
commit f023650730
14 changed files with 1448 additions and 20 deletions

View file

@ -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,

View file

@ -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,
)
},

View file

@ -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,
)
}
}

View file

@ -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)

View file

@ -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.

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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<FolderAddEditState, FolderAddEditEvent, FolderAddEditAction>(
// 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<FolderView?>,
) : Internal()
}
}

View file

@ -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()
}

View file

@ -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() },

View file

@ -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<FoldersEvent>()
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",
),
),
),
)

View file

@ -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<FolderView>()))
private val mutableFoldersStateFlow =
MutableStateFlow<DataState<List<FolderView>>>(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")

View file

@ -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<FolderAddEditEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE_ADD)
val viewModel = mockk<FolderAddEditViewModel>(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,
)

View file

@ -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<FolderView?>>(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"