diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/listitem/BitwardenGroupItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/listitem/BitwardenGroupItem.kt index 8e1c462b573..c87479eed29 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/listitem/BitwardenGroupItem.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/listitem/BitwardenGroupItem.kt @@ -10,16 +10,30 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.bitwarden.ui.platform.base.util.cardStyle +import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton +import com.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog +import com.bitwarden.ui.platform.components.dialog.row.BitwardenBasicDialogRow import com.bitwarden.ui.platform.components.icon.BitwardenIcon import com.bitwarden.ui.platform.components.icon.model.IconData import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.resource.BitwardenDrawable +import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf /** * A reusable composable function that displays a group item. @@ -33,6 +47,7 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme * @param modifier The [Modifier] to be applied to the [Row] composable that holds the list item. * @param subLabel The secondary text label to be displayed in the group item. * @param endIcon The [IconData] object used to draw the icon at the end of the group item. + * @param selectionDataList Optional overflow menu options shown via a more-options button. */ @Composable fun BitwardenGroupItem( @@ -44,14 +59,27 @@ fun BitwardenGroupItem( modifier: Modifier = Modifier, subLabel: String? = null, endIcon: IconData.Local? = null, + selectionDataList: ImmutableList = persistentListOf(), ) { + var shouldShowDialog by rememberSaveable { mutableStateOf(false) } Row( modifier = modifier .defaultMinSize(minHeight = 60.dp) - .cardStyle( - cardStyle = cardStyle, - onClick = onClick, - paddingHorizontal = 16.dp, + .then( + if (selectionDataList.isEmpty()) { + Modifier.cardStyle( + cardStyle = cardStyle, + onClick = onClick, + paddingHorizontal = 16.dp, + ) + } else { + Modifier.cardStyle( + cardStyle = cardStyle, + onClick = onClick, + paddingStart = 16.dp, + paddingEnd = 4.dp, + ) + }, ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -96,6 +124,35 @@ fun BitwardenGroupItem( .size(size = 24.dp), ) } + if (selectionDataList.isNotEmpty()) { + BitwardenStandardIconButton( + vectorIconRes = BitwardenDrawable.ic_ellipsis_horizontal, + contentDescription = stringResource(id = BitwardenString.more_options), + onClick = { shouldShowDialog = true }, + modifier = Modifier.testTag(tag = "FolderMoreOptionsButton"), + ) + } + } + + if (shouldShowDialog) { + BitwardenSelectionDialog( + title = label, + onDismissRequest = { shouldShowDialog = false }, + selectionItems = { + selectionDataList.forEach { itemData -> + BitwardenBasicDialogRow( + modifier = Modifier + .semantics { contentDescription = itemData.contentDescription } + .testTag(tag = "AlertSelectionOption"), + text = itemData.text, + onClick = { + shouldShowDialog = false + itemData.onClick() + }, + ) + } + }, + ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt index 5943314fab9..2c6a1716b63 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt @@ -57,6 +57,11 @@ class FolderAddEditViewModel @Inject constructor( }, ) { init { + vaultRepository + .foldersStateFlow + .onEach { sendAction(FolderAddEditAction.Internal.FoldersReceive(it)) } + .launchIn(viewModelScope) + state .folderAddEditType .folderId @@ -76,6 +81,7 @@ class FolderAddEditViewModel @Inject constructor( is FolderAddEditAction.NameTextChange -> handleNameTextChange(action) is FolderAddEditAction.SaveClick -> handleSaveClick() is FolderAddEditAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) + is FolderAddEditAction.Internal.FoldersReceive -> handleFoldersReceive(action) is FolderAddEditAction.Internal.CreateFolderResultReceive -> handleCreateFolderResultReceive(action) @@ -92,7 +98,7 @@ class FolderAddEditViewModel @Inject constructor( } private fun handleSaveClick() = onContent { content -> - if (content.folderName.isEmpty()) { + if (content.folderName.isBlank()) { mutableStateFlow.update { it.copy( dialog = FolderAddEditState.DialogState.Error( @@ -104,11 +110,46 @@ class FolderAddEditViewModel @Inject constructor( return@onContent } + val folderName = content.folderName.trim() + val resolvedFolderName = state + .parentFolderName + ?.let { "$it/" } + .orEmpty() + folderName + + if (!state.hasLoadedExistingFolders) { + mutableStateFlow.update { + it.copy( + dialog = FolderAddEditState.DialogState.Loading( + BitwardenString.saving.asText(), + ), + pendingSaveFolderName = resolvedFolderName, + ) + } + return@onContent + } + + if (isDuplicateFolderName(folderName = resolvedFolderName)) { + mutableStateFlow.update { + it.copy( + dialog = FolderAddEditState.DialogState.Error( + message = BitwardenString.a_folder_with_this_name_already_exists.asText(), + ), + pendingSaveFolderName = null, + ) + } + return@onContent + } + + createOrUpdateFolder(resolvedFolderName = resolvedFolderName) + } + + private fun createOrUpdateFolder(resolvedFolderName: String) = onContent { content -> mutableStateFlow.update { it.copy( dialog = FolderAddEditState.DialogState.Loading( BitwardenString.saving.asText(), ), + pendingSaveFolderName = null, ) } @@ -117,13 +158,7 @@ class FolderAddEditViewModel @Inject constructor( FolderAddEditType.AddItem -> { val result = vaultRepository.createFolder( FolderView( - name = state - .parentFolderName - ?.let { - "$it/" - } - .orEmpty() + - content.folderName, + name = resolvedFolderName, id = folderAddEditType.folderId, revisionDate = clock.instant(), ), @@ -135,7 +170,7 @@ class FolderAddEditViewModel @Inject constructor( val result = vaultRepository.updateFolder( folderAddEditType.folderId, FolderView( - name = content.folderName, + name = content.folderName.trim(), id = folderAddEditType.folderId, revisionDate = clock.instant(), ), @@ -146,6 +181,51 @@ class FolderAddEditViewModel @Inject constructor( } } + private fun isDuplicateFolderName(folderName: String): Boolean { + val currentFolderId = state.folderAddEditType.folderId + return state.existingFolders.any { folder -> + folder.id != currentFolderId && + folder.name.equals(folderName, ignoreCase = true) + } + } + + private fun handleFoldersReceive(action: FolderAddEditAction.Internal.FoldersReceive) { + val folders = action.foldersState.data + ?.map { folder -> + FolderAddEditState.ExistingFolder( + id = folder.id, + name = folder.name, + ) + } + .orEmpty() + val hasLoaded = action.foldersState !is DataState.Loading + val pendingSaveFolderName = state.pendingSaveFolderName + + mutableStateFlow.update { + it.copy( + existingFolders = folders, + hasLoadedExistingFolders = hasLoaded || folders.isNotEmpty(), + ) + } + + if (pendingSaveFolderName != null && (hasLoaded || folders.isNotEmpty())) { + if (isDuplicateFolderName(folderName = pendingSaveFolderName)) { + mutableStateFlow.update { + it.copy( + dialog = FolderAddEditState.DialogState.Error( + message = BitwardenString + .a_folder_with_this_name_already_exists + .asText(), + ), + pendingSaveFolderName = null, + ) + } + } else { + createOrUpdateFolder(resolvedFolderName = pendingSaveFolderName) + } + } + } + private fun handleDeleteClick() { val folderId = state.folderAddEditType.folderId ?: return @@ -361,8 +441,20 @@ data class FolderAddEditState( val viewState: ViewState, val dialog: DialogState?, val parentFolderName: String?, + val existingFolders: List = emptyList(), + val hasLoadedExistingFolders: Boolean = false, + val pendingSaveFolderName: String? = null, ) : Parcelable { + /** + * A previously created folder used for duplicate-name validation. + */ + @Parcelize + data class ExistingFolder( + val id: String?, + val name: String, + ) : Parcelable + /** * Helper to determine whether we show the overflow menu. */ @@ -499,5 +591,12 @@ sealed class FolderAddEditAction { data class VaultDataReceive( val vaultDataState: DataState, ) : Internal() + + /** + * Indicates that the list of existing folders has been received. + */ + data class FoldersReceive( + val foldersState: DataState>, + ) : Internal() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index d805022dc58..642b371d496 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -144,6 +144,11 @@ fun NavGraphBuilder.vaultUnlockedGraph( parentFolderName = it, ) }, + onNavigateToEditFolderScreen = { + navController.navigateToFolderAddEdit( + folderAddEditType = FolderAddEditType.EditItem(folderId = it), + ) + }, onNavigateToFlightRecorder = { navController.navigateToFlightRecorder(isPreAuth = false) }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt index 432bee344f0..a4f2536fe96 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt @@ -51,6 +51,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToRecordedLogs: () -> Unit, onNavigateToImportLogins: () -> Unit, onNavigateToAddFolderScreen: (selectedFolderName: String?) -> Unit, + onNavigateToEditFolderScreen: (folderId: String) -> Unit, onNavigateToAboutPrivilegedApps: () -> Unit, onNavigateToManageDevices: () -> Unit, onNavigateToPlan: () -> Unit, @@ -75,6 +76,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToSetupBrowserAutofill = onNavigateToSetupBrowserAutofill, onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToEditFolderScreen = onNavigateToEditFolderScreen, onNavigateToFlightRecorder = onNavigateToFlightRecorder, onNavigateToRecordedLogs = onNavigateToRecordedLogs, onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index b0c85afa173..1becbfb9abc 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -68,6 +68,7 @@ fun VaultUnlockedNavBarScreen( onNavigateToRecordedLogs: () -> Unit, onNavigateToImportLogins: () -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, + onNavigateToEditFolderScreen: (folderId: String) -> Unit, onNavigateToAboutPrivilegedApps: () -> Unit, onNavigateToManageDevices: () -> Unit, onNavigateToPlan: () -> Unit, @@ -109,6 +110,7 @@ fun VaultUnlockedNavBarScreen( onNavigateToSetupBrowserAutofill = onNavigateToSetupBrowserAutofill, onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToEditFolderScreen = onNavigateToEditFolderScreen, onNavigateToFlightRecorder = onNavigateToFlightRecorder, onNavigateToRecordedLogs = onNavigateToRecordedLogs, onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps, @@ -149,6 +151,7 @@ private fun VaultUnlockedNavBarScaffold( onNavigateToRecordedLogs: () -> Unit, onNavigateToImportLogins: () -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, + onNavigateToEditFolderScreen: (folderId: String) -> Unit, onNavigateToAboutPrivilegedApps: () -> Unit, onNavigateToManageDevices: () -> Unit, onNavigateToPlan: () -> Unit, @@ -203,6 +206,7 @@ private fun VaultUnlockedNavBarScaffold( onDimBottomNavBarRequest = { shouldDim -> shouldDimNavBar = shouldDim }, onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToEditFolderScreen = onNavigateToEditFolderScreen, onNavigateToAboutScreen = { navController.navigateToSettingsGraphRoot() navController.navigateToAbout(isPreAuth = false) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 83f88fc2b1b..2749aeca503 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -950,6 +950,39 @@ class VaultAddEditViewModel @Inject constructor( } private fun handleAddNewFolder(action: VaultAddEditAction.Common.AddNewFolder) { + val folderName = action.newFolderName.trim() + if (folderName.isEmpty()) { + mutableStateFlow.update { + it.copy( + dialog = VaultAddEditState.DialogState.Generic( + message = BitwardenString.validation_field_required + .asText(BitwardenString.name.asText()), + ), + ) + } + return + } + val existingFolderNames = (state.viewState as? VaultAddEditState.ViewState.Content) + ?.common + ?.availableFolders + ?.filter { it.id != null } + ?.map { it.name } + .orEmpty() + .ifEmpty { + vaultRepository.foldersStateFlow.value.data + ?.map { it.name } + .orEmpty() + } + if (existingFolderNames.any { it.equals(folderName, ignoreCase = true) }) { + mutableStateFlow.update { + it.copy( + dialog = VaultAddEditState.DialogState.Generic( + message = BitwardenString.a_folder_with_this_name_already_exists.asText(), + ), + ) + } + return + } mutableStateFlow.update { it.copy( dialog = VaultAddEditState.DialogState.Loading(BitwardenString.saving.asText()), @@ -958,7 +991,7 @@ class VaultAddEditViewModel @Inject constructor( viewModelScope.launch { val result = vaultRepository.createFolder( FolderView( - name = action.newFolderName, + name = folderName, id = null, revisionDate = clock.instant(), ), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index c8bea119b2e..50de228fd77 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -26,8 +26,10 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.components.listitem.BitwardenGroupItem +import com.x8bit.bitwarden.ui.platform.components.listitem.SelectionItemData import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers +import kotlinx.collections.immutable.persistentListOf private const val TOTP_TYPES_COUNT: Int = 1 private const val HIDDEN_TYPES_COUNT: Int = 2 @@ -396,6 +398,20 @@ fun VaultContent( label = folder.name(), supportingLabel = folder.itemCount.toString(), onClick = { vaultHandlers.folderClick(folder) }, + selectionDataList = folder.id + ?.let { + persistentListOf( + SelectionItemData( + text = stringResource(id = BitwardenString.edit), + onClick = { vaultHandlers.folderEditClick(folder) }, + ), + SelectionItemData( + text = stringResource(id = BitwardenString.delete), + onClick = { vaultHandlers.folderDeleteClick(folder) }, + ), + ) + } + ?: persistentListOf(), cardStyle = state .folderItems .toListItemCardStyle(index = index, dividerPadding = 56.dp), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt index 91a5563bcc2..c505fe19a48 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt @@ -32,6 +32,7 @@ fun NavGraphBuilder.vaultGraph( onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, onNavigateToImportLogins: () -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, + onNavigateToEditFolderScreen: (folderId: String) -> Unit, onNavigateToAboutScreen: () -> Unit, onNavigateToAutofillScreen: () -> Unit, onNavigateToPlan: () -> Unit, @@ -51,6 +52,7 @@ fun NavGraphBuilder.vaultGraph( onDimBottomNavBarRequest = onDimBottomNavBarRequest, onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToEditFolderScreen = onNavigateToEditFolderScreen, onNavigateToAboutScreen = onNavigateToAboutScreen, onNavigateToAutofillScreen = onNavigateToAutofillScreen, onNavigateToPlan = onNavigateToPlan, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt index a7255e7ef3e..6e5061fa79c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt @@ -34,6 +34,7 @@ fun NavGraphBuilder.vaultDestination( onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, onNavigateToImportLogins: () -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, + onNavigateToEditFolderScreen: (folderId: String) -> Unit, onNavigateToAboutScreen: () -> Unit, onNavigateToAutofillScreen: () -> Unit, onNavigateToPlan: () -> Unit, @@ -49,6 +50,7 @@ fun NavGraphBuilder.vaultDestination( onDimBottomNavBarRequest = onDimBottomNavBarRequest, onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToEditFolderScreen = onNavigateToEditFolderScreen, onNavigateToAboutScreen = onNavigateToAboutScreen, onNavigateToAutofillScreen = onNavigateToAutofillScreen, onNavigateToPlan = onNavigateToPlan, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index 4cdc7d0f560..24b24a652f9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -95,6 +95,7 @@ fun VaultScreen( onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, onNavigateToImportLogins: () -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, + onNavigateToEditFolderScreen: (folderId: String) -> Unit, onNavigateToAboutScreen: () -> Unit, onNavigateToAutofillScreen: () -> Unit, onNavigateToPlan: () -> Unit, @@ -171,6 +172,10 @@ fun VaultScreen( onNavigateToAddFolderScreen(null) } + is VaultEvent.NavigateToEditFolder -> { + onNavigateToEditFolderScreen(event.folderId) + } + VaultEvent.NavigateToAbout -> onNavigateToAboutScreen() is VaultEvent.ShowShareSheet -> { @@ -402,6 +407,18 @@ private fun VaultDialogs( ) } + is VaultState.DialogState.DeleteFolderConfirmation -> { + BitwardenTwoButtonDialog( + title = null, + message = stringResource(id = BitwardenString.do_you_really_want_to_delete), + dismissButtonText = stringResource(id = BitwardenString.cancel), + confirmButtonText = stringResource(id = BitwardenString.delete), + onConfirmClick = vaultHandlers.confirmDeleteFolderClick, + onDismissClick = vaultHandlers.dialogDismiss, + onDismissRequest = vaultHandlers.dialogDismiss, + ) + } + is VaultState.DialogState.Loading -> { BitwardenLoadingDialog(text = dialogState.message()) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 8cc52387f31..093a947a872 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -54,6 +54,7 @@ import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData @@ -325,6 +326,9 @@ class VaultViewModel @Inject constructor( is VaultAction.AddItemClick -> handleAddItemClick(action) is VaultAction.CardGroupClick -> handleCardClick() is VaultAction.FolderClick -> handleFolderItemClick(action) + is VaultAction.FolderEditClick -> handleFolderEditClick(action) + is VaultAction.FolderDeleteClick -> handleFolderDeleteClick(action) + VaultAction.ConfirmDeleteFolderClick -> handleConfirmDeleteFolderClick() is VaultAction.CollectionClick -> handleCollectionItemClick(action) is VaultAction.IdentityGroupClick -> handleIdentityClick() is VaultAction.VerificationCodesClick -> handleVerificationCodeClick() @@ -589,6 +593,65 @@ class VaultViewModel @Inject constructor( ) } + private fun handleFolderEditClick(action: VaultAction.FolderEditClick) { + val folderId = action.folderItem.id ?: return + sendEvent(VaultEvent.NavigateToEditFolder(folderId = folderId)) + } + + private fun handleFolderDeleteClick(action: VaultAction.FolderDeleteClick) { + val folderId = action.folderItem.id ?: return + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.DeleteFolderConfirmation( + folderId = folderId, + folderName = action.folderItem.name, + ), + ) + } + } + + private fun handleConfirmDeleteFolderClick() { + val dialog = state.dialog as? VaultState.DialogState.DeleteFolderConfirmation ?: return + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.Loading( + message = BitwardenString.deleting.asText(), + ), + ) + } + viewModelScope.launch { + sendAction( + VaultAction.Internal.DeleteFolderResultReceive( + result = vaultRepository.deleteFolder(folderId = dialog.folderId), + ), + ) + } + } + + private fun handleDeleteFolderResultReceive( + action: VaultAction.Internal.DeleteFolderResultReceive, + ) { + when (val result = action.result) { + is DeleteFolderResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = result.errorMessage?.asText() + ?: BitwardenString.generic_error_message.asText(), + error = result.error, + ), + ) + } + } + + DeleteFolderResult.Success -> { + mutableStateFlow.update { it.copy(dialog = null) } + sendEvent(VaultEvent.ShowSnackbar(BitwardenString.folder_deleted.asText())) + } + } + } + private fun handleCollectionItemClick(action: VaultAction.CollectionClick) { sendEvent( VaultEvent.NavigateToItemListing( @@ -1141,6 +1204,7 @@ class VaultViewModel @Inject constructor( is VaultAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action) is VaultAction.Internal.UnarchiveCipherReceive -> handleUnarchiveCipherReceive(action) + is VaultAction.Internal.DeleteFolderResultReceive -> handleDeleteFolderResultReceive(action) is VaultAction.Internal.IntroducingArchiveActionCardDismissedFlowReceive -> { handleIntroducingArchiveActionCardDismissedFlowReceive(action) } @@ -2239,6 +2303,15 @@ data class VaultState( @Parcelize data object ArchiveRequiresPremium : DialogState() + /** + * Represents a dialog prompting the user to confirm deleting a folder. + */ + @Parcelize + data class DeleteFolderConfirmation( + val folderId: String, + val folderName: Text, + ) : DialogState() + /** * Displays a dialog with a loading indicator. */ @@ -2408,6 +2481,13 @@ sealed class VaultEvent { */ data object NavigateToAddFolder : VaultEvent() + /** + * Navigate to the edit folder screen. + */ + data class NavigateToEditFolder( + val folderId: String, + ) : VaultEvent() + /** * Navigate to settings. */ @@ -2513,6 +2593,25 @@ sealed class VaultAction { val folderItem: VaultState.ViewState.FolderItem, ) : VaultAction() + /** + * Action to trigger when editing a folder from the overflow menu. + */ + data class FolderEditClick( + val folderItem: VaultState.ViewState.FolderItem, + ) : VaultAction() + + /** + * Action to trigger when deleting a folder from the overflow menu. + */ + data class FolderDeleteClick( + val folderItem: VaultState.ViewState.FolderItem, + ) : VaultAction() + + /** + * Action to confirm deleting the folder shown in the confirmation dialog. + */ + data object ConfirmDeleteFolderClick : VaultAction() + /** * Action to trigger when a specific collection item is clicked. */ @@ -2784,6 +2883,13 @@ sealed class VaultAction { val result: ArchiveCipherResult, ) : Internal() + /** + * Indicates that the delete folder result has been received. + */ + data class DeleteFolderResultReceive( + val result: DeleteFolderResult, + ) : Internal() + /** * Indicates that the unarchive cipher result has been received. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt index 2bb0288a293..274329bdfd7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt @@ -24,6 +24,9 @@ data class VaultHandlers( val lockAction: () -> Unit, val vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit, val folderClick: (VaultState.ViewState.FolderItem) -> Unit, + val folderEditClick: (VaultState.ViewState.FolderItem) -> Unit, + val folderDeleteClick: (VaultState.ViewState.FolderItem) -> Unit, + val confirmDeleteFolderClick: () -> Unit, val collectionClick: (VaultState.ViewState.CollectionItem) -> Unit, val verificationCodesClick: () -> Unit, val loginGroupClick: () -> Unit, @@ -86,6 +89,11 @@ data class VaultHandlers( lockAction = { viewModel.trySendAction(VaultAction.LockClick) }, vaultItemClick = { viewModel.trySendAction(VaultAction.VaultItemClick(it)) }, folderClick = { viewModel.trySendAction(VaultAction.FolderClick(it)) }, + folderEditClick = { viewModel.trySendAction(VaultAction.FolderEditClick(it)) }, + folderDeleteClick = { viewModel.trySendAction(VaultAction.FolderDeleteClick(it)) }, + confirmDeleteFolderClick = { + viewModel.trySendAction(VaultAction.ConfirmDeleteFolderClick) + }, collectionClick = { viewModel.trySendAction(VaultAction.CollectionClick(it)) }, verificationCodesClick = { viewModel.trySendAction(VaultAction.VerificationCodesClick) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt index 364a8c17e85..48f9be15812 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt @@ -40,9 +40,12 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { private val mutableFoldersStateFlow = MutableStateFlow>(DataState.Loading) + private val mutableFoldersListStateFlow = + MutableStateFlow>>(DataState.Loaded(emptyList())) private val vaultRepository: VaultRepository = mockk { every { getVaultFolderStateFlow(DEFAULT_EDIT_ITEM_ID) } returns mutableFoldersStateFlow + every { foldersStateFlow } returns mutableFoldersListStateFlow } private val relayManager: SnackbarRelayManager = mockk { every { sendSnackbarData(data = any(), relay = any()) } just runs @@ -160,6 +163,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { folderName = DEFAULT_FOLDER_NAME, ), parentFolderName = null, + hasLoadedExistingFolders = true, ) val stateWithoutDialog = stateWithDialog.copy( @@ -210,6 +214,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { BitwardenString.generic_error_message.asText(), ), parentFolderName = null, + hasLoadedExistingFolders = true, ) val viewModel = createViewModel( @@ -239,6 +244,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { folderName = DEFAULT_FOLDER_NAME, ), parentFolderName = null, + hasLoadedExistingFolders = true, ) val stateWithoutDialog = stateWithDialog.copy( @@ -285,6 +291,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { folderName = DEFAULT_FOLDER_NAME, ), parentFolderName = null, + hasLoadedExistingFolders = true, ) val viewModel = createViewModel( @@ -332,6 +339,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { folderName = "", ), parentFolderName = null, + hasLoadedExistingFolders = true, ) val stateWithDialog = stateWithoutName.copy( @@ -354,6 +362,91 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { assertEquals(stateWithDialog, viewModel.stateFlow.value) } + @Test + fun `SaveClick with duplicate folder name should show an error dialog`() = runTest { + mutableFoldersListStateFlow.value = DataState.Loaded( + listOf( + FolderView( + id = "existing-id", + name = DEFAULT_FOLDER_NAME, + revisionDate = FIXED_CLOCK.instant(), + ), + ), + ) + val stateWithoutDialog = FolderAddEditState( + folderAddEditType = FolderAddEditType.AddItem, + dialog = null, + viewState = FolderAddEditState.ViewState.Content( + folderName = DEFAULT_FOLDER_NAME, + ), + parentFolderName = null, + hasLoadedExistingFolders = true, + ) + val viewModel = createViewModel( + createSavedStateHandleWithState(state = stateWithoutDialog), + ) + + viewModel.trySendAction(FolderAddEditAction.SaveClick) + + assertEquals( + stateWithoutDialog.copy( + existingFolders = listOf( + FolderAddEditState.ExistingFolder( + id = "existing-id", + name = DEFAULT_FOLDER_NAME, + ), + ), + hasLoadedExistingFolders = true, + dialog = FolderAddEditState.DialogState.Error( + message = BitwardenString.a_folder_with_this_name_already_exists.asText(), + ), + ), + viewModel.stateFlow.value, + ) + coVerify(exactly = 0) { vaultRepository.createFolder(any()) } + } + + @Test + fun `SaveClick in edit mode with unchanged name should allow saving`() = runTest { + mutableFoldersListStateFlow.value = DataState.Loaded( + listOf( + FolderView( + id = DEFAULT_EDIT_ITEM_ID, + name = DEFAULT_FOLDER_NAME, + revisionDate = FIXED_CLOCK.instant(), + ), + ), + ) + val stateWithoutDialog = FolderAddEditState( + folderAddEditType = FolderAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID), + dialog = null, + viewState = FolderAddEditState.ViewState.Content( + folderName = DEFAULT_FOLDER_NAME, + ), + parentFolderName = null, + hasLoadedExistingFolders = true, + ) + val viewModel = createViewModel( + createSavedStateHandleWithState(state = stateWithoutDialog), + ) + mutableFoldersStateFlow.value = DataState.Loaded( + FolderView( + id = DEFAULT_EDIT_ITEM_ID, + name = DEFAULT_FOLDER_NAME, + revisionDate = FIXED_CLOCK.instant(), + ), + ) + coEvery { + vaultRepository.updateFolder(any(), any()) + } returns UpdateFolderResult.Success(mockk()) + + viewModel.trySendAction(FolderAddEditAction.SaveClick) + + coVerify(exactly = 1) { + vaultRepository.updateFolder(any(), any()) + } + } + @Suppress("MaxLineLength") @Test fun `in add mode, SaveClick createFolder success should show dialog, and remove it once an item is saved`() = @@ -367,6 +460,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { folderName = DEFAULT_FOLDER_NAME, ), parentFolderName = null, + hasLoadedExistingFolders = true, ) val stateWithoutDialog = stateWithDialog.copy( @@ -411,6 +505,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { ), dialog = null, parentFolderName = null, + hasLoadedExistingFolders = true, ), ), ) @@ -485,6 +580,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { folderName = DEFAULT_FOLDER_NAME, ), parentFolderName = null, + hasLoadedExistingFolders = true, ) val viewModel = createViewModel( @@ -523,6 +619,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { folderName = DEFAULT_FOLDER_NAME, ), parentFolderName = null, + hasLoadedExistingFolders = true, ) val viewModel = createViewModel( @@ -564,6 +661,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { folderName = DEFAULT_FOLDER_NAME, ), parentFolderName = null, + hasLoadedExistingFolders = true, ) val stateWithoutDialog = stateWithDialog.copy( @@ -616,6 +714,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { folderName = DEFAULT_FOLDER_NAME, ), parentFolderName = null, + hasLoadedExistingFolders = true, ) val viewModel = createViewModel( @@ -663,6 +762,7 @@ class FolderAddEditViewModelTest : BaseViewModelTest() { folderName = DEFAULT_FOLDER_NAME, ), parentFolderName = null, + hasLoadedExistingFolders = true, ) val viewModel = createViewModel( @@ -940,6 +1040,8 @@ private val DEFAULT_STATE = FolderAddEditState( dialog = FolderAddEditState.DialogState.Loading("Loading".asText()), folderAddEditType = FolderAddEditType.AddItem, parentFolderName = null, + existingFolders = emptyList(), + hasLoadedExistingFolders = true, ) private val FIXED_CLOCK = Clock.fixed( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt index e3c64110bdf..86415eb58d8 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt @@ -61,6 +61,7 @@ class VaultUnlockedNavBarScreenTest : BitwardenComposeTest() { onNavigateToSetupUnlockScreen = {}, onNavigateToImportLogins = {}, onNavigateToAddFolderScreen = {}, + onNavigateToEditFolderScreen = {}, onNavigateToFlightRecorder = {}, onNavigateToRecordedLogs = {}, onNavigateToAboutPrivilegedApps = {}, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index dd697ee5931..eb222ea3b93 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -123,6 +123,7 @@ class VaultScreenTest : BitwardenComposeTest() { onNavigateToAddFolderCalled = true onNavigateToAddFolderParentFolderName = folderName }, + onNavigateToEditFolderScreen = {}, onNavigateToAboutScreen = { onNavigateToAboutCalled = true }, onNavigateToAutofillScreen = { onNavigateToAutofillCalled = true }, onNavigateToPlan = { onNavigateToPlanCalled = true }, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 057c15b5ea8..16dc6a863c6 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -67,6 +67,7 @@ import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData @@ -2231,6 +2232,67 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `FolderEditClick should emit NavigateToEditFolder`() = runTest { + val viewModel = createViewModel() + val folderId = "folder-id" + val folder = mockk { + every { id } returns folderId + } + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAction.FolderEditClick(folder)) + assertEquals( + VaultEvent.NavigateToEditFolder(folderId = folderId), + awaitItem(), + ) + } + } + + @Test + fun `FolderDeleteClick should show DeleteFolderConfirmation dialog`() = runTest { + val viewModel = createViewModel() + val folderId = "folder-id" + val folderName = "Himalya".asText() + val folder = mockk { + every { id } returns folderId + every { name } returns folderName + } + + viewModel.trySendAction(VaultAction.FolderDeleteClick(folder)) + + assertEquals( + VaultState.DialogState.DeleteFolderConfirmation( + folderId = folderId, + folderName = folderName, + ), + viewModel.stateFlow.value.dialog, + ) + } + + @Test + fun `ConfirmDeleteFolderClick success should clear dialog and show snackbar`() = runTest { + val folderId = "folder-id" + val folderName = "Himalya".asText() + val folder = mockk { + every { id } returns folderId + every { name } returns folderName + } + coEvery { vaultRepository.deleteFolder(folderId = folderId) } returns + DeleteFolderResult.Success + val viewModel = createViewModel() + viewModel.trySendAction(VaultAction.FolderDeleteClick(folder)) + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAction.ConfirmDeleteFolderClick) + assertEquals( + VaultEvent.ShowSnackbar(BitwardenString.folder_deleted.asText()), + awaitItem(), + ) + } + assertEquals(null, viewModel.stateFlow.value.dialog) + coVerify(exactly = 1) { vaultRepository.deleteFolder(folderId = folderId) } + } + @Suppress("MaxLineLength") @Test fun `CollectionClick should emit NavigateToItemListing event with Collection type with the correct collection ID`() = diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 97fd7fbead6..797bd5ac8cc 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ Enter your PIN code Favorites Folder + A folder with this name already exists. New folder created. Folder deleted. No Folder