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.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -16,6 +17,7 @@ import androidx.compose.ui.unit.dp
* @param label The label for the button. * @param label The label for the button.
* @param onClick The callback when the button is clicked. * @param onClick The callback when the button is clicked.
* @param modifier The [Modifier] to be applied to the button. * @param modifier The [Modifier] to be applied to the button.
* @param labelTextColor The color for the label text.
*/ */
@Composable @Composable
fun BitwardenTextButton( fun BitwardenTextButton(
@ -23,12 +25,21 @@ fun BitwardenTextButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isEnabled: Boolean = true, isEnabled: Boolean = true,
labelTextColor: Color? = null,
) { ) {
val defaultColors = if (labelTextColor != null) {
ButtonDefaults.textButtonColors(
contentColor = labelTextColor,
)
} else {
ButtonDefaults.textButtonColors()
}
TextButton( TextButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = isEnabled, enabled = isEnabled,
colors = ButtonDefaults.textButtonColors(), colors = defaultColors,
) { ) {
Text( Text(
text = label, text = label,

View file

@ -4,6 +4,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/** /**
* Represents a Bitwarden-styled dialog with two buttons. * 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 onDismissClick called when the dismiss button is clicked.
* @param onDismissRequest called when the user attempts to dismiss the dialog (for example by * @param onDismissRequest called when the user attempts to dismiss the dialog (for example by
* tapping outside of it). * tapping outside of it).
* @param confirmTextColor The color of the confirm text.
* @param dismissTextColor The color of the dismiss text.
*/ */
@Composable @Composable
fun BitwardenTwoButtonDialog( fun BitwardenTwoButtonDialog(
@ -26,18 +29,22 @@ fun BitwardenTwoButtonDialog(
onConfirmClick: () -> Unit, onConfirmClick: () -> Unit,
onDismissClick: () -> Unit, onDismissClick: () -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
confirmTextColor: Color? = null,
dismissTextColor: Color? = null,
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
dismissButton = { dismissButton = {
BitwardenTextButton( BitwardenTextButton(
label = dismissButtonText, label = dismissButtonText,
labelTextColor = dismissTextColor,
onClick = onDismissClick, onClick = onDismissClick,
) )
}, },
confirmButton = { confirmButton = {
BitwardenTextButton( BitwardenTextButton(
label = confirmButtonText, label = confirmButtonText,
labelTextColor = confirmTextColor,
onClick = onConfirmClick, onClick = onConfirmClick,
) )
}, },

View file

@ -12,12 +12,16 @@ private const val FOLDERS_ROUTE = "settings_folders"
*/ */
fun NavGraphBuilder.foldersDestination( fun NavGraphBuilder.foldersDestination(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToAddFolderScreen: () -> Unit,
onNavigateToEditFolderScreen: (folderId: String) -> Unit,
) { ) {
composableWithSlideTransitions( composableWithSlideTransitions(
route = FOLDERS_ROUTE, route = FOLDERS_ROUTE,
) { ) {
FoldersScreen( FoldersScreen(
onNavigateBack = onNavigateBack, 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.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect 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.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.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
@ -50,6 +49,8 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderDisp
@Composable @Composable
fun FoldersScreen( fun FoldersScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToAddFolderScreen: () -> Unit,
onNavigateToEditFolderScreen: (folderId: String) -> Unit,
viewModel: FoldersViewModel = hiltViewModel(), viewModel: FoldersViewModel = hiltViewModel(),
) { ) {
val state = viewModel.stateFlow.collectAsStateWithLifecycle() val state = viewModel.stateFlow.collectAsStateWithLifecycle()
@ -57,13 +58,9 @@ fun FoldersScreen(
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
is FoldersEvent.NavigateBack -> onNavigateBack() is FoldersEvent.NavigateBack -> onNavigateBack()
is FoldersEvent.NavigateToAddFolderScreen -> { is FoldersEvent.NavigateToAddFolderScreen -> onNavigateToAddFolderScreen()
showNotYetImplementedToast(context = context) is FoldersEvent.NavigateToEditFolderScreen ->
} onNavigateToEditFolderScreen(event.folderId)
is FoldersEvent.NavigateToEditFolderScreen -> {
showNotYetImplementedToast(context = context)
}
is FoldersEvent.ShowToast -> { is FoldersEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
@ -108,7 +105,7 @@ fun FoldersScreen(
FoldersContent( FoldersContent(
foldersList = viewState.folderList, foldersList = viewState.folderList,
onItemClick = remember(viewModel) { onItemClick = remember(viewModel) {
{ viewModel.trySendAction(FoldersAction.OnFolderClick(it)) } { viewModel.trySendAction(FoldersAction.FolderClick(it)) }
}, },
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)

View file

@ -39,10 +39,10 @@ class FoldersViewModel @Inject constructor(
is FoldersAction.AddFolderButtonClick -> handleAddFolderButtonClicked() is FoldersAction.AddFolderButtonClick -> handleAddFolderButtonClicked()
is FoldersAction.CloseButtonClick -> handleCloseButtonClicked() is FoldersAction.CloseButtonClick -> handleCloseButtonClicked()
is FoldersAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) 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)) sendEvent(FoldersEvent.NavigateToEditFolderScreen(action.folderId))
} }
@ -196,7 +196,7 @@ sealed class FoldersAction {
/** /**
* Indicates that the user clicked a folder. * 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. * 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.navigateToPendingRequests
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.pendingRequestsDestination 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.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.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.VAULT_UNLOCKED_NAV_BAR_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
import com.x8bit.bitwarden.ui.tools.feature.generator.generatorModalDestination import com.x8bit.bitwarden.ui.tools.feature.generator.generatorModalDestination
@ -128,7 +131,19 @@ fun NavGraphBuilder.vaultUnlockedGraph(
addSendDestination(onNavigateBack = { navController.popBackStack() }) addSendDestination(onNavigateBack = { navController.popBackStack() })
passwordHistoryDestination(onNavigateBack = { navController.popBackStack() }) passwordHistoryDestination(onNavigateBack = { navController.popBackStack() })
exportVaultDestination(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() }) generatorModalDestination(onNavigateBack = { navController.popBackStack() })
searchDestination( searchDestination(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },

View file

@ -1,13 +1,21 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.folders 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.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest 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.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -15,6 +23,8 @@ import org.junit.Test
class FoldersScreenTest : BaseComposeTest() { class FoldersScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false private var onNavigateBackCalled = false
private var onNavigateToEditFolderScreenId: String? = null
private var onNavigateToAddFolderScreenCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<FoldersEvent>() private val mutableEventFlow = bufferedMutableSharedFlow<FoldersEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -28,11 +38,33 @@ class FoldersScreenTest : BaseComposeTest() {
composeTestRule.setContent { composeTestRule.setContent {
FoldersScreen( FoldersScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToEditFolderScreen = { onNavigateToEditFolderScreenId = it },
onNavigateToAddFolderScreen = { onNavigateToAddFolderScreenCalled = true },
onNavigateBack = { onNavigateBackCalled = 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 @Test
fun `close button click should send CloseButtonClick`() { fun `close button click should send CloseButtonClick`() {
composeTestRule.onNodeWithContentDescription("Close").performClick() composeTestRule.onNodeWithContentDescription("Close").performClick()
@ -48,11 +80,90 @@ class FoldersScreenTest : BaseComposeTest() {
} }
@Test @Test
fun `NavigateBack should call onNavigateBack`() { fun `error text should display according to state`() {
mutableEventFlow.tryEmit(FoldersEvent.NavigateBack) val message = "An error has occurred"
assertTrue(onNavigateBackCalled)
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 = private val DEFAULT_STATE =
FoldersState(viewState = FoldersState.ViewState.Loading) 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 package com.x8bit.bitwarden.ui.platform.feature.settings.folders
import app.cash.turbine.test import app.cash.turbine.test
import com.bitwarden.core.DateTime
import com.bitwarden.core.FolderView import com.bitwarden.core.FolderView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest 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.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -14,7 +19,8 @@ import org.junit.jupiter.api.Test
class FoldersViewModelTest : BaseViewModelTest() { 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 { private val vaultRepository: VaultRepository = mockk {
every { foldersStateFlow } returns mutableFoldersStateFlow 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( private fun createViewModel(): FoldersViewModel = FoldersViewModel(
vaultRepository = vaultRepository, 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"