PM-18032 Adding a new folder while adding or editing an item. (#4731)

This commit is contained in:
Dave Severns
2025-02-18 17:25:51 -05:00
committed by GitHub
parent 87f64d7aba
commit 0ba67f5887
11 changed files with 826 additions and 167 deletions
@@ -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,
)
@@ -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<CustomAccessibilityAction> = 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,
)
}
}
@@ -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,
@@ -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<AddEditItemCoachMark>.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 {
@@ -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<String>,
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())
}
}
}
@@ -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<List<FolderView>>,
) : Internal()
}
}
@@ -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,
),
)
},
)
}
}
@@ -178,7 +178,11 @@ private fun List<FolderView>.toSelectedFolderId(cipherView: CipherView?): String
?.folderId
?.takeIf { id -> id in map { it.id } }
private fun List<FolderView>.toAvailableFolders(
/**
* Maps a list of [FolderView]s to a list of available [VaultAddEditState.Folder]s with
* a default first item of "None."
*/
fun List<FolderView>.toAvailableFolders(
resourceManager: ResourceManager,
): List<VaultAddEditState.Folder> =
listOf(
@@ -188,7 +192,7 @@ private fun List<FolderView>.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? =
+1 -1
View File
@@ -2,7 +2,7 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="TypographyEllipsis,TypographyDashes">
<string name="about">About</string>
<string name="add">Add</string>
<string name="add_folder">Add Folder</string>
<string name="add_folder">Add folder</string>
<string name="add_item">Add Item</string>
<string name="an_error_has_occurred">An error has occurred.</string>
<string name="back">Back</string>
@@ -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(
@@ -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<TotpCodeResult> = bufferedMutableSharedFlow()
private val mutableVaultDataFlow = MutableStateFlow<DataState<VaultData>>(DataState.Loading)
private val mutableFolderStateFlow = MutableStateFlow<DataState<List<FolderView>>>(
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<AuthRepository>(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,