mirror of
https://github.com/bitwarden/android.git
synced 2024-11-23 09:56:11 +03:00
BIT-458, BIT-459: Add screens for adding and editing folders (#795)
This commit is contained in:
parent
9338a51d68
commit
f023650730
14 changed files with 1448 additions and 20 deletions
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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() },
|
||||||
|
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
|
@ -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"
|
Loading…
Reference in a new issue