From 0ba67f588798bd82890098371ecbfd7f881d0fca Mon Sep 17 00:00:00 2001 From: Dave Severns <149429124+dseverns-livefront@users.noreply.github.com> Date: Tue, 18 Feb 2025 17:25:51 -0500 Subject: [PATCH] PM-18032 Adding a new folder while adding or editing an item. (#4731) --- .../bottomsheet/BitwardenModalBottomSheet.kt | 6 + .../button/BitwardenTextSelectionButton.kt | 174 +++++++++++++++++ .../dropdown/BitwardenMultiSelectButton.kt | 146 ++------------ .../addedit/VaultAddEditItemContent.kt | 10 +- .../feature/addedit/VaultAddEditScreen.kt | 181 ++++++++++++++++++ .../feature/addedit/VaultAddEditViewModel.kt | 124 +++++++++++- .../handlers/VaultAddEditCommonHandlers.kt | 32 +++- .../addedit/util/CipherViewExtensions.kt | 8 +- app/src/main/res/values/strings.xml | 2 +- .../feature/addedit/VaultAddEditScreenTest.kt | 157 +++++++++++++-- .../addedit/VaultAddEditViewModelTest.kt | 153 ++++++++++++++- 11 files changed, 826 insertions(+), 167 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenTextSelectionButton.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt index 1dee3ee955..f80c820702 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.platform.components.bottomsheet +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api @@ -30,6 +31,7 @@ import kotlinx.coroutines.launch * @param onDismiss The action to perform when the bottom sheet is dismissed will also be performed * when the "close" icon is clicked, caller must handle any desired animation or hiding of the * bottom sheet. This will be invoked _after_ the sheet has been animated away. + * @param topBarActions Row of actions to add the top bar of the bottom sheet. * @param showBottomSheet Whether or not to show the bottom sheet, by default this is true assuming * the showing/hiding will be handled by the caller. * @param sheetContent Content to display in the bottom sheet. The content is passed the padding @@ -42,6 +44,7 @@ fun BitwardenModalBottomSheet( sheetTitle: String, onDismiss: () -> Unit, modifier: Modifier = Modifier, + topBarActions: @Composable RowScope.(animatedOnDismiss: () -> Unit) -> Unit = {}, showBottomSheet: Boolean = true, sheetState: SheetState = rememberModalBottomSheetState(), sheetContent: @Composable (animatedOnDismiss: () -> Unit) -> Unit, @@ -68,6 +71,9 @@ fun BitwardenModalBottomSheet( onNavigationIconClick = animatedOnDismiss, navigationIconContentDescription = stringResource(R.string.close), ), + actions = { + topBarActions(animatedOnDismiss) + }, scrollBehavior = scrollBehavior, minimumHeight = 64.dp, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenTextSelectionButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenTextSelectionButton.kt new file mode 100644 index 0000000000..0ceecf70ec --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/button/BitwardenTextSelectionButton.kt @@ -0,0 +1,174 @@ +package com.x8bit.bitwarden.ui.platform.components.button + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.cardStyle +import com.x8bit.bitwarden.ui.platform.base.util.nullableTestTag +import com.x8bit.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider +import com.x8bit.bitwarden.ui.platform.components.field.color.bitwardenTextFieldButtonColors +import com.x8bit.bitwarden.ui.platform.components.model.CardStyle +import com.x8bit.bitwarden.ui.platform.components.model.TooltipData +import com.x8bit.bitwarden.ui.platform.components.row.BitwardenRowOfActions +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * A button which uses a read-only text field for layout and style purposes. + */ +@Suppress("LongMethod") +@Composable +fun BitwardenTextSelectionButton( + label: String, + selectedOption: String?, + onClick: () -> Unit, + cardStyle: CardStyle?, + modifier: Modifier = Modifier, + enabled: Boolean = false, + tooltipEnabled: Boolean = true, + supportingText: String? = null, + tooltip: TooltipData? = null, + insets: PaddingValues = PaddingValues(), + textFieldTestTag: String? = null, + semanticRole: Role = Role.Button, + semanticContentDescription: String = supportingText + ?.let { "$selectedOption. $label. $it" } + ?: "$selectedOption. $label", + customAccessibilityActions: List = listOfNotNull( + tooltip?.let { + CustomAccessibilityAction( + label = it.contentDescription, + action = { + it.onClick() + true + }, + ) + }, + ), + actionsPadding: PaddingValues = PaddingValues(end = 4.dp), + actions: @Composable RowScope.() -> Unit = {}, +) { + Column( + modifier = modifier + .clearAndSetSemantics { + role = semanticRole + contentDescription = semanticContentDescription + customActions = customAccessibilityActions + } + .cardStyle( + cardStyle = cardStyle, + paddingTop = 6.dp, + paddingBottom = 0.dp, + onClick = onClick, + ) + .padding(paddingValues = insets), + ) { + TextField( + textStyle = BitwardenTheme.typography.bodyLarge, + readOnly = true, + label = { + Row { + Text( + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + tooltip?.let { + Spacer(modifier = Modifier.width(8.dp)) + BitwardenStandardIconButton( + vectorIconRes = R.drawable.ic_question_circle_small, + contentDescription = it.contentDescription, + onClick = it.onClick, + isEnabled = tooltipEnabled, + contentColor = BitwardenTheme.colorScheme.icon.secondary, + modifier = Modifier.size(16.dp), + ) + } + } + }, + trailingIcon = { + BitwardenRowOfActions( + modifier = Modifier.padding(paddingValues = actionsPadding), + actions = { + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_chevron_down), + contentDescription = null, + tint = BitwardenTheme.colorScheme.icon.primary, + modifier = Modifier.minimumInteractiveComponentSize(), + ) + actions() + }, + ) + }, + value = selectedOption.orEmpty(), + onValueChange = {}, + enabled = enabled, + colors = bitwardenTextFieldButtonColors(), + modifier = Modifier + .nullableTestTag(tag = textFieldTestTag) + .fillMaxWidth(), + ) + supportingText + ?.let { content -> + Spacer(modifier = Modifier.height(height = 6.dp)) + BitwardenHorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp), + ) + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .defaultMinSize(minHeight = 48.dp) + .padding(vertical = 12.dp, horizontal = 16.dp), + content = { + Text( + text = content, + style = BitwardenTheme.typography.bodySmall, + color = BitwardenTheme.colorScheme.text.secondary, + modifier = Modifier.fillMaxWidth(), + ) + }, + ) + } + ?: Spacer(modifier = Modifier.height(height = cardStyle?.let { 6.dp } ?: 0.dp)) + } +} + +@Preview +@Composable +private fun BitwardenTextSelectionButton_preview() { + BitwardenTheme { + BitwardenTextSelectionButton( + label = "Folder", + selectedOption = "No Folder", + onClick = {}, + cardStyle = CardStyle.Full, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dropdown/BitwardenMultiSelectButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dropdown/BitwardenMultiSelectButton.kt index cc2dcd4330..d998b466b2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dropdown/BitwardenMultiSelectButton.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dropdown/BitwardenMultiSelectButton.kt @@ -1,50 +1,23 @@ package com.x8bit.bitwarden.ui.platform.components.dropdown -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.minimumInteractiveComponentSize 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.Modifier -import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.customActions -import androidx.compose.ui.semantics.role -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.asText -import com.x8bit.bitwarden.ui.platform.base.util.cardStyle -import com.x8bit.bitwarden.ui.platform.base.util.nullableTestTag -import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextSelectionButton import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenSelectionRow -import com.x8bit.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider -import com.x8bit.bitwarden.ui.platform.components.field.color.bitwardenTextFieldButtonColors import com.x8bit.bitwarden.ui.platform.components.model.CardStyle import com.x8bit.bitwarden.ui.platform.components.model.TooltipData -import com.x8bit.bitwarden.ui.platform.components.row.BitwardenRowOfActions -import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -73,7 +46,6 @@ import kotlinx.collections.immutable.persistentListOf * in the app bar's trailing side. This lambda extends [RowScope], allowing flexibility in * defining the layout of the actions. */ -@Suppress("LongMethod") @Composable fun BitwardenMultiSelectButton( label: String, @@ -92,103 +64,25 @@ fun BitwardenMultiSelectButton( ) { var shouldShowDialog by rememberSaveable { mutableStateOf(false) } - Column( - modifier = modifier - .clearAndSetSemantics { - role = Role.DropdownList - contentDescription = supportingText - ?.let { "$selectedOption. $label. $it" } - ?: "$selectedOption. $label" - customActions = listOfNotNull( - tooltip?.let { - CustomAccessibilityAction( - label = it.contentDescription, - action = { - it.onClick() - true - }, - ) - }, - ) - } - .cardStyle( - cardStyle = cardStyle, - paddingTop = 6.dp, - paddingBottom = 0.dp, - onClick = { shouldShowDialog = !shouldShowDialog }, - ) - .padding(paddingValues = insets), - ) { - TextField( - textStyle = BitwardenTheme.typography.bodyLarge, - readOnly = true, - label = { - Row { - Text( - text = label, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - tooltip?.let { - Spacer(modifier = Modifier.width(3.dp)) - BitwardenStandardIconButton( - vectorIconRes = R.drawable.ic_question_circle_small, - contentDescription = it.contentDescription, - onClick = it.onClick, - isEnabled = isEnabled, - contentColor = BitwardenTheme.colorScheme.icon.secondary, - modifier = Modifier.size(16.dp), - ) - } - } - }, - trailingIcon = { - BitwardenRowOfActions( - modifier = Modifier.padding(paddingValues = actionsPadding), - actions = { - Icon( - painter = rememberVectorPainter(id = R.drawable.ic_chevron_down), - contentDescription = null, - tint = BitwardenTheme.colorScheme.icon.primary, - modifier = Modifier.minimumInteractiveComponentSize(), - ) - actions() - }, - ) - }, - value = selectedOption.orEmpty(), - onValueChange = onOptionSelected, - enabled = shouldShowDialog, - colors = bitwardenTextFieldButtonColors(), - modifier = Modifier - .nullableTestTag(tag = textFieldTestTag) - .fillMaxWidth(), - ) - supportingText - ?.let { content -> - Spacer(modifier = Modifier.height(height = 6.dp)) - BitwardenHorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp), - ) - Column( - verticalArrangement = Arrangement.Center, - modifier = Modifier - .defaultMinSize(minHeight = 48.dp) - .padding(vertical = 12.dp, horizontal = 16.dp), - content = { - Text( - text = content, - style = BitwardenTheme.typography.bodySmall, - color = BitwardenTheme.colorScheme.text.secondary, - modifier = Modifier.fillMaxWidth(), - ) - }, - ) - } - ?: Spacer(modifier = Modifier.height(height = cardStyle?.let { 6.dp } ?: 0.dp)) - } + BitwardenTextSelectionButton( + label = label, + selectedOption = selectedOption, + onClick = { + shouldShowDialog = true + }, + cardStyle = cardStyle, + enabled = shouldShowDialog, + tooltipEnabled = isEnabled, + supportingText = supportingText, + tooltip = tooltip, + insets = insets, + textFieldTestTag = textFieldTestTag, + actionsPadding = actionsPadding, + actions = actions, + semanticRole = Role.DropdownList, + modifier = modifier, + ) + if (shouldShowDialog) { BitwardenSelectionDialog( title = label, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt index 5deabf22a3..3ad7288b8e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextSelectionButton import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard import com.x8bit.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkScope @@ -149,15 +150,10 @@ fun CoachMarkScope.VaultAddEditContent( item { Spacer(modifier = Modifier.height(height = 8.dp)) - BitwardenMultiSelectButton( + BitwardenTextSelectionButton( label = stringResource(id = R.string.folder), - options = state.common.availableFolders.map { it.name }.toImmutableList(), selectedOption = state.common.selectedFolder?.name, - onOptionSelected = { selectedFolderName -> - commonTypeHandlers.onFolderSelected( - state.common.availableFolders.first { it.name == selectedFolderName }, - ) - }, + onClick = commonTypeHandlers.onSelectOrAddFolderForItem, cardStyle = if (isAddItemMode && state.common.hasOrganizations) { CardStyle.Top(dividerPadding = 0.dp) } else { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index 8e68426e96..991e379816 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -1,11 +1,24 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -14,11 +27,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -26,10 +42,13 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.cardStyle +import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon import com.x8bit.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.appbar.action.OverflowMenuItemData +import com.x8bit.bitwarden.ui.platform.components.bottomsheet.BitwardenModalBottomSheet import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkContainer import com.x8bit.bitwarden.ui.platform.components.coachmark.rememberLazyListCoachMarkState @@ -41,7 +60,11 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPassword import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasskeyConfirmationDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenPinDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField +import com.x8bit.bitwarden.ui.platform.components.model.CardStyle +import com.x8bit.bitwarden.ui.platform.components.radio.BitwardenRadioButton import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager @@ -53,6 +76,7 @@ import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.platform.util.persistentListOfNotNull import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditCardTypeHandlers @@ -61,6 +85,8 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditIdentit import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditLoginTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditSshKeyTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditUserVerificationHandlers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch /** @@ -394,6 +420,11 @@ fun VaultAddEditScreen( .imePadding() .fillMaxSize(), ) + FolderSelectionBottomSheet( + state = viewState.common, + handlers = commonTypeHandlers, + showBottomSheet = state.shouldShowFolderSelectionBottomSheet, + ) } is VaultAddEditState.ViewState.Error -> { @@ -517,3 +548,153 @@ private fun VaultAddEditItemDialogs( null -> Unit } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FolderSelectionBottomSheet( + state: VaultAddEditState.ViewState.Content.Common, + handlers: VaultAddEditCommonHandlers, + showBottomSheet: Boolean, + modifier: Modifier = Modifier, +) { + var selectedOptionState by rememberSaveable { + mutableStateOf(state.selectedFolder?.name.orEmpty()) + } + BitwardenModalBottomSheet( + sheetTitle = stringResource(R.string.folders), + onDismiss = handlers.onDismissFolderSelectionSheet, + topBarActions = { animatedOnDismiss -> + BitwardenTextButton( + label = stringResource(R.string.save), + onClick = { + handlers.onDismissFolderSelectionSheet() + state + .availableFolders + .firstOrNull { + it.name == selectedOptionState + } + ?.run { + handlers.onChangeToExistingFolder(this.id) + } + ?: run { + handlers.onOnAddFolder(selectedOptionState) + } + animatedOnDismiss() + }, + isEnabled = selectedOptionState.isNotBlank(), + ) + }, + showBottomSheet = showBottomSheet, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + modifier = modifier.statusBarsPadding(), + ) { + FolderSelectionBottomSheetContent( + options = state.availableFolders.map { it.name }.toImmutableList(), + selectedOption = selectedOptionState, + onOptionSelected = { + selectedOptionState = it + }, + ) + } +} + +@Suppress("LongMethod") +@Composable +private fun FolderSelectionBottomSheetContent( + options: ImmutableList, + selectedOption: String, + onOptionSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .standardHorizontalMargin(), + ) { + item { + Spacer(modifier = Modifier.height(12.dp)) + } + itemsIndexed(options) { index, option -> + Row( + modifier = Modifier + .fillMaxWidth() + .cardStyle( + cardStyle = if (index == 0) { + CardStyle.Top() + } else { + CardStyle.Middle() + }, + onClick = { + onOptionSelected(option) + }, + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = option, + color = BitwardenTheme.colorScheme.text.primary, + style = BitwardenTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 16.dp), + ) + BitwardenRadioButton( + isSelected = selectedOption == option, + onClick = { + onOptionSelected(option) + }, + ) + } + } + item { + var inEditMode by rememberSaveable { + mutableStateOf(false) + } + var addFolderText by rememberSaveable { + mutableStateOf("") + } + val cardStyle = if (options.isEmpty()) CardStyle.Full else CardStyle.Bottom + if (inEditMode) { + BitwardenTextField( + label = stringResource(R.string.add_folder), + value = addFolderText, + onValueChange = { + addFolderText = it + onOptionSelected(it) + }, + autoFocus = true, + cardStyle = cardStyle, + modifier = Modifier + .fillMaxWidth(), + actions = { + BitwardenRadioButton( + isSelected = selectedOption == addFolderText, + onClick = { + onOptionSelected(addFolderText) + }, + ) + }, + ) + } else { + BitwardenClickableText( + label = stringResource(id = R.string.add_folder), + onClick = { + onOptionSelected(addFolderText) + inEditMode = true + }, + leadingIcon = painterResource(id = R.drawable.ic_plus_small), + style = BitwardenTheme.typography.labelMedium, + innerPadding = PaddingValues(all = 16.dp), + cornerSize = 0.dp, + modifier = Modifier + .fillMaxWidth() + .cardStyle(cardStyle = cardStyle, paddingVertical = 0.dp), + ) + } + } + item { + Spacer(modifier = Modifier.height(16.dp)) + } + item { + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 718a511ec6..1a7c43c064 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -3,7 +3,9 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.core.DateTime import com.bitwarden.vault.CipherView +import com.bitwarden.vault.FolderView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult @@ -35,6 +37,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult @@ -50,6 +53,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldType import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem import com.x8bit.bitwarden.ui.vault.feature.addedit.model.toCustomField import com.x8bit.bitwarden.ui.vault.feature.addedit.util.appendFolderAndOwnerData +import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toAvailableFolders import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toDefaultAddTypeContent import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toItemType import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toViewState @@ -176,6 +180,7 @@ class VaultAddEditViewModel @Inject constructor( shouldExitOnSave = shouldExitOnSave, shouldShowCoachMarkTour = false, shouldClearSpecialCircumstance = autofillSelectionData == null, + shouldShowFolderSelectionBottomSheet = false, ) }, ) { @@ -206,6 +211,12 @@ class VaultAddEditViewModel @Inject constructor( .onEach(::sendAction) .launchIn(viewModelScope) + vaultRepository + .foldersStateFlow + .map { VaultAddEditAction.Internal.AvailableFoldersReceive(folderData = it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + generatorRepository .generatorResultFlow .map { result -> @@ -251,7 +262,6 @@ class VaultAddEditViewModel @Inject constructor( handleCustomFieldValueChange(action) } - is VaultAddEditAction.Common.FolderChange -> handleFolderTextInputChange(action) is VaultAddEditAction.Common.NameTextChange -> handleNameTextInputChange(action) is VaultAddEditAction.Common.NotesTextChange -> handleNotesTextInputChange(action) is VaultAddEditAction.Common.OwnershipChange -> handleOwnershipTextInputChange(action) @@ -335,6 +345,19 @@ class VaultAddEditViewModel @Inject constructor( VaultAddEditAction.Common.DismissFido2VerificationDialogClick -> { handleDismissFido2VerificationDialogClick() } + + is VaultAddEditAction.Common.FolderChange -> handleFolderTextInputChange(action) + VaultAddEditAction.Common.DismissFolderSelectionBottomSheet -> { + handleDismissFolderSelectionBottomSheet() + } + + VaultAddEditAction.Common.SelectOrAddFolderForItem -> { + handleSelectOrAddFolderForItem() + } + + is VaultAddEditAction.Common.AddNewFolder -> { + handleAddNewFolder(action) + } } } @@ -694,6 +717,36 @@ class VaultAddEditViewModel @Inject constructor( showFido2ErrorDialog() } + private fun handleDismissFolderSelectionBottomSheet() { + mutableStateFlow.update { + it.copy(shouldShowFolderSelectionBottomSheet = false) + } + } + + private fun handleSelectOrAddFolderForItem() { + mutableStateFlow.update { + it.copy(shouldShowFolderSelectionBottomSheet = true) + } + } + + private fun handleAddNewFolder(action: VaultAddEditAction.Common.AddNewFolder) { + mutableStateFlow.update { + it.copy( + dialog = VaultAddEditState.DialogState.Loading(R.string.saving.asText()), + ) + } + viewModelScope.launch { + val result = vaultRepository.createFolder( + FolderView( + name = action.newFolderName, + id = null, + revisionDate = DateTime.now(), + ), + ) + sendAction(VaultAddEditAction.Internal.AddFolderResultReceive(result = result)) + } + } + private fun handleAddNewCustomFieldClick( action: VaultAddEditAction.Common.AddNewCustomFieldClick, ) { @@ -792,7 +845,7 @@ class VaultAddEditViewModel @Inject constructor( action: VaultAddEditAction.Common.FolderChange, ) { updateCommonContent { commonContent -> - commonContent.copy(selectedFolderId = action.folder.id) + commonContent.copy(selectedFolderId = action.folderId) } } @@ -1427,9 +1480,29 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditAction.Internal.ShouldShowAddLoginCoachMarkValueChangeReceive -> { handleShouldShowAddLoginCoachMarkValueChange(action) } + + is VaultAddEditAction.Internal.AddFolderResultReceive -> handleAddFolderResult(action) + is VaultAddEditAction.Internal.AvailableFoldersReceive -> { + handleAvailableFoldersReceive(action) + } } } + private fun handleAvailableFoldersReceive( + action: VaultAddEditAction.Internal.AvailableFoldersReceive, + ) { + action + .folderData + .data + ?.let { + updateCommonContent { commonContent -> + commonContent.copy( + availableFolders = it.toAvailableFolders(resourceManager = resourceManager), + ) + } + } + } + private fun handleShouldShowAddLoginCoachMarkValueChange( action: VaultAddEditAction.Internal.ShouldShowAddLoginCoachMarkValueChangeReceive, ) { @@ -1440,6 +1513,19 @@ class VaultAddEditViewModel @Inject constructor( } } + private fun handleAddFolderResult(action: VaultAddEditAction.Internal.AddFolderResultReceive) { + mutableStateFlow.update { + it.copy( + dialog = null, + ) + } + updateCommonContent { + it.copy( + selectedFolderId = (action.result as? CreateFolderResult.Success)?.folderView?.id, + ) + } + } + private fun handleCreateCipherResultReceive( action: VaultAddEditAction.Internal.CreateCipherResultReceive, ) { @@ -1943,6 +2029,7 @@ data class VaultAddEditState( val vaultAddEditType: VaultAddEditType, val viewState: ViewState, val dialog: DialogState?, + val shouldShowFolderSelectionBottomSheet: Boolean, val shouldShowCloseButton: Boolean = true, // Internal val shouldExitOnSave: Boolean = false, @@ -2621,9 +2708,9 @@ sealed class VaultAddEditAction { /** * Fired when the folder text input is changed. * - * @property folder The new folder text. + * @property folderId The new folder id. */ - data class FolderChange(val folder: VaultAddEditState.Folder) : Common() + data class FolderChange(val folderId: String?) : Common() /** * Fired when the Favorite toggle is changed. @@ -2767,6 +2854,21 @@ sealed class VaultAddEditAction { * The user has clicked to dismiss the FIDO 2 password or PIN verification dialog. */ data object DismissFido2VerificationDialogClick : Common() + + /** + * The user has clicked on folder selection card for the item. + */ + data object SelectOrAddFolderForItem : Common() + + /** + * The user has dismissed the folder selection bottom sheet. + */ + data object DismissFolderSelectionBottomSheet : Common() + + /** + * The user has selected to add a new folder to associate with the item. + */ + data class AddNewFolder(val newFolderName: String) : Common() } /** @@ -3164,5 +3266,19 @@ sealed class VaultAddEditAction { data class ShouldShowAddLoginCoachMarkValueChangeReceive( val shouldShowCoachMarkTour: Boolean, ) : Internal() + + /** + * Received a result for attempting to add a folder. + */ + data class AddFolderResultReceive( + val result: CreateFolderResult, + ) : Internal() + + /** + * Received an update to the available folders. + */ + data class AvailableFoldersReceive( + val folderData: DataState>, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditCommonHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditCommonHandlers.kt index 59209ea366..e1793c32ac 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditCommonHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditCommonHandlers.kt @@ -12,7 +12,7 @@ import com.x8bit.bitwarden.ui.vault.model.VaultCollection * within the context of adding items to a vault. * * @property onNameTextChange Handles the action when the name text is changed. - * @property onFolderSelected Handles the action when a folder is selected. + * @property onSelectOrAddFolderForItem Handles the action when a folder is selected. * @property onToggleFavorite Handles the action when the favorite toggle is changed. * @property onToggleMasterPasswordReprompt Handles the action when the master password * reprompt toggle is changed. @@ -27,7 +27,6 @@ import com.x8bit.bitwarden.ui.vault.model.VaultCollection @Suppress("LongParameterList") data class VaultAddEditCommonHandlers( val onNameTextChange: (String) -> Unit, - val onFolderSelected: (VaultAddEditState.Folder) -> Unit, val onToggleFavorite: (Boolean) -> Unit, val onToggleMasterPasswordReprompt: (Boolean) -> Unit, val onNotesTextChange: (String) -> Unit, @@ -38,6 +37,10 @@ data class VaultAddEditCommonHandlers( val onCustomFieldActionSelect: (CustomFieldAction, VaultAddEditState.Custom) -> Unit, val onCollectionSelect: (VaultCollection) -> Unit, val onHiddenFieldVisibilityChange: (Boolean) -> Unit, + val onSelectOrAddFolderForItem: () -> Unit, + val onDismissFolderSelectionSheet: () -> Unit, + val onChangeToExistingFolder: (String?) -> Unit, + val onOnAddFolder: (String) -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -54,11 +57,9 @@ data class VaultAddEditCommonHandlers( VaultAddEditAction.Common.NameTextChange(newName), ) }, - onFolderSelected = { newFolder -> + onSelectOrAddFolderForItem = { viewModel.trySendAction( - VaultAddEditAction.Common.FolderChange( - newFolder, - ), + VaultAddEditAction.Common.SelectOrAddFolderForItem, ) }, onToggleFavorite = { isFavorite -> @@ -123,6 +124,25 @@ data class VaultAddEditCommonHandlers( VaultAddEditAction.Common.HiddenFieldVisibilityChange(isVisible = it), ) }, + onDismissFolderSelectionSheet = { + viewModel.trySendAction( + VaultAddEditAction.Common.DismissFolderSelectionBottomSheet, + ) + }, + onChangeToExistingFolder = { + viewModel.trySendAction( + VaultAddEditAction.Common.FolderChange( + folderId = it, + ), + ) + }, + onOnAddFolder = { + viewModel.trySendAction( + VaultAddEditAction.Common.AddNewFolder( + newFolderName = it, + ), + ) + }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt index 805dda6e13..2518d85381 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt @@ -178,7 +178,11 @@ private fun List.toSelectedFolderId(cipherView: CipherView?): String ?.folderId ?.takeIf { id -> id in map { it.id } } -private fun List.toAvailableFolders( +/** + * Maps a list of [FolderView]s to a list of available [VaultAddEditState.Folder]s with + * a default first item of "None." + */ +fun List.toAvailableFolders( resourceManager: ResourceManager, ): List = listOf( @@ -188,7 +192,7 @@ private fun List.toAvailableFolders( ), ) .plus( - map { VaultAddEditState.Folder(name = it.name, id = it.id.toString()) }, + map { VaultAddEditState.Folder(name = it.name, id = it.id) }, ) private fun UserState.Account.toSelectedOwnerId(cipherView: CipherView?): String? = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b2815bf3bc..2887fe7c21 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ About Add - Add Folder + Add folder Add Item An error has occurred. Back diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index e6d75a5d17..b9827154e2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -1,6 +1,9 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed @@ -31,6 +34,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput @@ -38,6 +42,7 @@ import androidx.core.net.toUri import com.bitwarden.vault.UriMatchType import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest @@ -2466,7 +2471,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { } @Test - fun `clicking a Folder Option should send FolderChange action`() { + fun `clicking a Folder Option should send SelectOrAddFolderForItem action`() { updateStateWithFolders() // Opens the menu @@ -2474,21 +2479,9 @@ class VaultAddEditScreenTest : BaseComposeTest() { .onNodeWithContentDescriptionAfterScroll(label = "No Folder. Folder") .performClick() - // Choose the option from the menu - composeTestRule - .onAllNodesWithText(text = "mockFolderName-1") - .onLast() - .performScrollTo() - .performClick() - verify { viewModel.trySendAction( - VaultAddEditAction.Common.FolderChange( - VaultAddEditState.Folder( - id = "mockFolderId-1", - name = "mockFolderName-1", - ), - ), + VaultAddEditAction.Common.SelectOrAddFolderForItem, ) } } @@ -2510,6 +2503,135 @@ class VaultAddEditScreenTest : BaseComposeTest() { .assertIsDisplayed() } + @Test + fun `should show folder selection bottom sheet when state updates to true`() { + mutableStateFlow.update { + it.copy(shouldShowFolderSelectionBottomSheet = true) + } + + composeTestRule + .onNodeWithText("Folders") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Add folder") + .assertIsDisplayed() + } + + @Test + fun `DismissFolderSelectionBottomSheet action sent when bottom sheet close button click`() { + mutableStateFlow.update { + it.copy(shouldShowFolderSelectionBottomSheet = true) + } + + composeTestRule + .onNodeWithText("Folders") + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithContentDescription("Close") + .filterToOne(hasAnySibling(hasText("Folders"))) + .assertIsDisplayed() + .performSemanticsAction(SemanticsActions.OnClick) + + dispatcher.advanceTimeByAndRunCurrent(1000L) + + verify { + viewModel.trySendAction(VaultAddEditAction.Common.DismissFolderSelectionBottomSheet) + } + } + + @Suppress("MaxLineLength") + @Test + fun `Clicking add folder button in bottom sheet hides add button and replaced with TextField`() { + mutableStateFlow.update { + it.copy(shouldShowFolderSelectionBottomSheet = true) + } + + composeTestRule + .onNodeWithText("Folders") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Add folder") + .assertIsDisplayed() + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.EditableText)) + .performSemanticsAction(SemanticsActions.OnClick) + + composeTestRule + .onNodeWithText("Add folder") + .assertIsDisplayed() + .assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText)) + } + + @Test + fun `Editing the add folder text and clicking save send AddFolder action`() { + mutableStateFlow.update { + it.copy(shouldShowFolderSelectionBottomSheet = true) + } + val newFolderName = "newFolderName" + + composeTestRule + .onNodeWithText("Folders") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Add folder") + .assertIsDisplayed() + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.EditableText)) + .performSemanticsAction(SemanticsActions.OnClick) + + composeTestRule + .onNodeWithText("Add folder") + .assertIsDisplayed() + .assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText)) + .performTextInput(newFolderName) + + composeTestRule + .onAllNodesWithText("Save") + .filterToOne(hasAnySibling(hasText("Folders"))) + .assertIsDisplayed() + .performSemanticsAction(SemanticsActions.OnClick) + + verify { + viewModel.trySendAction(VaultAddEditAction.Common.AddNewFolder(newFolderName)) + } + } + + @Test + fun `Selecting existing option and clicking save on folder sheet sends FolderChange action`() { + val folderId = "1234" + val folderName = "name" + mutableStateFlow.update { currentState -> + updateCommonContent(currentState) { + copy( + availableFolders = + listOf( + VaultAddEditState.Folder( + id = folderId, + name = folderName, + ), + ), + ) + } + .copy(shouldShowFolderSelectionBottomSheet = true) + } + + composeTestRule + .onNodeWithText(folderName) + .performSemanticsAction(SemanticsActions.OnClick) + + composeTestRule + .onAllNodesWithText("Save") + .filterToOne(hasAnySibling(hasText("Folders"))) + .assertIsDisplayed() + .performSemanticsAction(SemanticsActions.OnClick) + + verify { + viewModel.trySendAction(VaultAddEditAction.Common.FolderChange(folderId = folderId)) + } + } + @Test fun `toggling the favorite toggle should send ToggleFavorite action`() { mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES @@ -3885,6 +4007,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { dialog = VaultAddEditState.DialogState.Generic(message = "test".asText()), vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN), shouldShowCoachMarkTour = false, + shouldShowFolderSelectionBottomSheet = false, ) private val DEFAULT_STATE_LOGIN = VaultAddEditState( @@ -3896,6 +4019,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { ), dialog = null, shouldShowCoachMarkTour = false, + shouldShowFolderSelectionBottomSheet = false, ) private val DEFAULT_STATE_IDENTITY = VaultAddEditState( @@ -3907,6 +4031,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { ), dialog = null, shouldShowCoachMarkTour = false, + shouldShowFolderSelectionBottomSheet = false, ) private val DEFAULT_STATE_CARD = VaultAddEditState( @@ -3918,6 +4043,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { ), dialog = null, shouldShowCoachMarkTour = false, + shouldShowFolderSelectionBottomSheet = false, ) private val DEFAULT_STATE_SECURE_NOTES_CUSTOM_FIELDS = VaultAddEditState( @@ -3939,6 +4065,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { dialog = null, vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.SECURE_NOTE), shouldShowCoachMarkTour = false, + shouldShowFolderSelectionBottomSheet = false, ) private val DEFAULT_STATE_SECURE_NOTES = VaultAddEditState( @@ -3950,6 +4077,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { ), dialog = null, shouldShowCoachMarkTour = false, + shouldShowFolderSelectionBottomSheet = false, ) private val DEFAULT_STATE_SSH_KEYS = VaultAddEditState( @@ -3961,6 +4089,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { ), dialog = null, shouldShowCoachMarkTour = false, + shouldShowFolderSelectionBottomSheet = false, ) private val ALTERED_COLLECTIONS = listOf( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index c75e9c0b2b..d461ceab87 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit import android.content.pm.SigningInfo import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.bitwarden.core.DateTime import com.bitwarden.send.SendView import com.bitwarden.vault.CipherView import com.bitwarden.vault.CollectionView @@ -54,6 +55,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createViewCollectionV import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createViewExceptPasswordsCollectionView import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult @@ -66,7 +68,6 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldAction import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldType import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem -import com.x8bit.bitwarden.ui.vault.feature.addedit.model.toCustomField import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAttestationOptions import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toDefaultAddTypeContent import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toViewState @@ -133,6 +134,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { private val totpTestCodeFlow: MutableSharedFlow = bufferedMutableSharedFlow() private val mutableVaultDataFlow = MutableStateFlow>(DataState.Loading) + private val mutableFolderStateFlow = MutableStateFlow>>( + DataState.Loading, + ) private val resourceManager: ResourceManager = mockk { every { getString(R.string.folder_none) } returns "No Folder" } @@ -154,6 +158,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { private val vaultRepository: VaultRepository = mockk { every { vaultDataStateFlow } returns mutableVaultDataFlow every { totpCodeFlow } returns totpTestCodeFlow + every { foldersStateFlow } returns mutableFolderStateFlow } private val mockAuthRepository = mockk(relaxed = true) @@ -187,7 +192,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @AfterEach fun tearDown() { unmockkStatic(CipherView::toViewState) - unmockkStatic(CustomFieldType::toCustomField) + unmockkStatic(UUID::randomUUID) } @Test @@ -204,6 +209,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { shouldShowCloseButton = true, shouldExitOnSave = false, shouldShowCoachMarkTour = false, + shouldShowFolderSelectionBottomSheet = false, ) val viewModel = createAddVaultItemViewModel( savedStateHandle = createSavedStateHandleWithState( @@ -285,6 +291,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ), dialog = null, shouldShowCoachMarkTour = false, + shouldShowFolderSelectionBottomSheet = false, ), viewModel.stateFlow.value, ) @@ -3151,10 +3158,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Test fun `FolderChange should update folder`() = runTest { val action = VaultAddEditAction.Common.FolderChange( - VaultAddEditState.Folder( - id = "mockId-1", - name = "Folder 1", - ), + folderId = "mockId-1", ) viewModel.trySendAction(action) @@ -3171,6 +3175,139 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { assertEquals(expectedState, viewModel.stateFlow.value) } + @Test + fun `SelectOrAddFolderFoItem should update state to show bottom sheet`() = runTest { + val action = VaultAddEditAction.Common.SelectOrAddFolderForItem + + viewModel.trySendAction(action) + + val expectedState = vaultAddItemInitialState.copy( + shouldShowFolderSelectionBottomSheet = true, + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `DismissFolderSelectionBottomSheet should update state to hide bottom sheet`() = + runTest { + val action = VaultAddEditAction.Common.DismissFolderSelectionBottomSheet + + viewModel.trySendAction(VaultAddEditAction.Common.SelectOrAddFolderForItem) + + assertEquals( + vaultAddItemInitialState.copy( + shouldShowFolderSelectionBottomSheet = true, + ), + viewModel.stateFlow.value, + ) + val expectedState = vaultAddItemInitialState.copy( + shouldShowFolderSelectionBottomSheet = false, + ) + viewModel.trySendAction(action) + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + + @Test + fun `AddNewFolder action calls create folder from vault repository`() = runTest { + mockkStatic(DateTime::class) + every { DateTime.now() } returns Instant.MIN + + val folderName = "folderName" + val expectedFolderResult = FolderView( + id = "123", + name = folderName, + revisionDate = DateTime.now(), + ) + coEvery { + vaultRepository.createFolder(any()) + } returns CreateFolderResult.Success(expectedFolderResult) + viewModel.trySendAction(VaultAddEditAction.Common.AddNewFolder(folderName)) + coVerify { + vaultRepository.createFolder( + FolderView( + name = folderName, + id = null, + revisionDate = Instant.MIN, + ), + ) + } + + unmockkStatic(DateTime::class) + } + + @Test + fun `AddNewFolder updates dialog states and selected folder id on success`() = runTest { + + val folderId = "123" + val folderName = "folderName" + val expectedFolderResult = FolderView( + id = folderId, + name = folderName, + revisionDate = DateTime.now(), + ) + coEvery { + vaultRepository.createFolder(any()) + } returns CreateFolderResult.Success(expectedFolderResult) + + viewModel.stateFlow.test { + awaitItem() // initial state. + viewModel.trySendAction(VaultAddEditAction.Common.AddNewFolder(folderName)) + assertEquals( + vaultAddItemInitialState.copy( + dialog = VaultAddEditState.DialogState.Loading(R.string.saving.asText()), + ), + awaitItem(), + ) + assertEquals( + createVaultAddItemState( + dialogState = null, + commonContentViewState = createCommonContentViewState( + selectedFolderId = folderId, + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `State updates when available folders state is updated`() { + mutableFolderStateFlow.update { + DataState.Loaded( + data = listOf( + FolderView( + name = "folder", + revisionDate = DateTime.now(), + id = null, + ), + ), + ) + } + val folderList = listOf( + VaultAddEditState.Folder( + id = null, + name = "No Folder", + ), + VaultAddEditState.Folder( + id = null, + name = "folder", + ), + ) + + assertEquals( + createVaultAddItemState( + commonContentViewState = createCommonContentViewState( + availableFolders = folderList.toList(), + ), + ), + viewModel.stateFlow.value, + ) + } + @Test fun `ToggleFavorite should update favorite`() = runTest { val action = VaultAddEditAction.Common.ToggleFavorite(true) @@ -4277,6 +4414,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = totpData, shouldShowCoachMarkTour = false, shouldClearSpecialCircumstance = shouldClearSpecialCircumstance, + shouldShowFolderSelectionBottomSheet = false, ) @Suppress("LongParameterList") @@ -4298,10 +4436,11 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { hasOrganizations: Boolean = true, canDelete: Boolean = true, canAssociateToCollections: Boolean = true, + selectedFolderId: String? = null, ): VaultAddEditState.ViewState.Content.Common = VaultAddEditState.ViewState.Content.Common( name = name, - selectedFolderId = null, + selectedFolderId = selectedFolderId, favorite = favorite, customFieldData = customFieldData, masterPasswordReprompt = masterPasswordReprompt,