Vault screen overflow option actions (#739)

This commit is contained in:
David Perez 2024-01-23 18:32:57 -06:00 committed by Álison Fernandes
parent 9a371843ee
commit 376278e97a
8 changed files with 285 additions and 14 deletions

View file

@ -79,6 +79,8 @@ fun VaultContent(
label = favoriteItem.name(),
supportingLabel = favoriteItem.supportingLabel?.invoke(),
onClick = { vaultHandlers.vaultItemClick(favoriteItem) },
overflowOptions = favoriteItem.overflowOptions,
onOverflowOptionClick = vaultHandlers.overflowOptionClick,
modifier = Modifier
.fillMaxWidth()
.padding(
@ -227,12 +229,13 @@ fun VaultContent(
)
}
items(state.noFolderItems) { noFolderItem ->
VaultEntryListItem(
startIcon = noFolderItem.startIcon,
label = noFolderItem.name(),
supportingLabel = noFolderItem.supportingLabel?.invoke(),
onClick = { vaultHandlers.vaultItemClick(noFolderItem) },
overflowOptions = noFolderItem.overflowOptions,
onOverflowOptionClick = vaultHandlers.overflowOptionClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),

View file

@ -1,16 +1,15 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenListItem
import com.x8bit.bitwarden.ui.platform.components.SelectionItemData
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import kotlinx.collections.immutable.persistentListOf
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import kotlinx.collections.immutable.toImmutableList
/**
* A Composable function that displays a row item for different types of vault entries.
@ -18,6 +17,8 @@ import kotlinx.collections.immutable.persistentListOf
* @param label The primary text label to display for the item.
* @param supportingLabel An optional secondary text label to display beneath the primary label.
* @param onClick The lambda to be invoked when the item is clicked.
* @param overflowOptions List of options to display for the item.
* @param onOverflowOptionClick The lambda to be invoked when an overflow option is clicked.
* @param modifier An optional [Modifier] for this Composable, defaulting to an empty Modifier.
* This allows the caller to specify things like padding, size, etc.
*/
@ -26,25 +27,25 @@ fun VaultEntryListItem(
startIcon: IconData,
label: String,
onClick: () -> Unit,
overflowOptions: List<ListingItemOverflowAction.VaultAction>,
onOverflowOptionClick: (ListingItemOverflowAction.VaultAction) -> Unit,
modifier: Modifier = Modifier,
supportingLabel: String? = null,
) {
val context = LocalContext.current
BitwardenListItem(
modifier = modifier,
label = label,
supportingLabel = supportingLabel,
startIcon = startIcon,
onClick = onClick,
selectionDataList = persistentListOf(
SelectionItemData(
text = "Not yet implemented",
onClick = {
// TODO: Provide dialog-based implementation (BIT-1353 - BIT-1356)
Toast.makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT).show()
},
),
),
selectionDataList = overflowOptions
.map { option ->
SelectionItemData(
text = option.title(),
onClick = { onOverflowOptionClick(option) },
)
}
.toImmutableList(),
)
}
@ -57,6 +58,8 @@ private fun VaultEntryListItem_preview() {
label = "Example Login",
supportingLabel = "Username",
onClick = {},
overflowOptions = emptyList(),
onOverflowOptionClick = {},
modifier = Modifier,
)
}

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
@ -51,7 +52,9 @@ import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.theme.LocalExitManager
import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import kotlinx.collections.immutable.persistentListOf
@ -73,6 +76,7 @@ fun VaultScreen(
onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit,
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
exitManager: ExitManager = LocalExitManager.current,
intentManager: IntentManager = LocalIntentManager.current,
) {
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
@ -102,6 +106,8 @@ fun VaultScreen(
onNavigateToVaultItemListingScreen(event.itemListingType)
}
is VaultEvent.NavigateToUrl -> intentManager.launchUri(event.url.toUri())
VaultEvent.NavigateOutOfApp -> exitManager.exitApplication()
is VaultEvent.ShowToast -> {
Toast

View file

@ -7,6 +7,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
@ -19,6 +20,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.base.util.hexToColor
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
@ -43,6 +45,7 @@ import javax.inject.Inject
@Suppress("TooManyFunctions")
@HiltViewModel
class VaultViewModel @Inject constructor(
private val clipboardManager: BitwardenClipboardManager,
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
private val settingsRepository: SettingsRepository,
@ -128,6 +131,7 @@ class VaultViewModel @Inject constructor(
is VaultAction.TryAgainClick -> handleTryAgainClick()
is VaultAction.DialogDismiss -> handleDialogDismiss()
is VaultAction.RefreshPull -> handleRefreshPull()
is VaultAction.OverflowOptionClick -> handleOverflowOptionClick(action)
is VaultAction.Internal -> handleInternalAction(action)
}
}
@ -270,6 +274,82 @@ class VaultViewModel @Inject constructor(
vaultRepository.sync()
}
private fun handleOverflowOptionClick(action: VaultAction.OverflowOptionClick) {
when (val overflowAction = action.overflowAction) {
is ListingItemOverflowAction.VaultAction.CopyNoteClick -> {
handleCopyNoteClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.CopyNumberClick -> {
handleCopyNumberClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.CopyPasswordClick -> {
handleCopyPasswordClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.CopySecurityCodeClick -> {
handleCopySecurityCodeClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.CopyUsernameClick -> {
handleCopyUsernameClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.EditClick -> {
handleEditClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.LaunchClick -> {
handleLaunchClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.ViewClick -> {
handleViewClick(overflowAction)
}
}
}
private fun handleCopyNoteClick(action: ListingItemOverflowAction.VaultAction.CopyNoteClick) {
clipboardManager.setText(action.notes)
}
private fun handleCopyNumberClick(
action: ListingItemOverflowAction.VaultAction.CopyNumberClick,
) {
clipboardManager.setText(action.number)
}
private fun handleCopyPasswordClick(
action: ListingItemOverflowAction.VaultAction.CopyPasswordClick,
) {
clipboardManager.setText(action.password)
}
private fun handleCopySecurityCodeClick(
action: ListingItemOverflowAction.VaultAction.CopySecurityCodeClick,
) {
clipboardManager.setText(action.securityCode)
}
private fun handleCopyUsernameClick(
action: ListingItemOverflowAction.VaultAction.CopyUsernameClick,
) {
clipboardManager.setText(action.username)
}
private fun handleEditClick(action: ListingItemOverflowAction.VaultAction.EditClick) {
sendEvent(VaultEvent.NavigateToEditVaultItem(action.cipherId))
}
private fun handleLaunchClick(action: ListingItemOverflowAction.VaultAction.LaunchClick) {
sendEvent(VaultEvent.NavigateToUrl(action.url))
}
private fun handleViewClick(action: ListingItemOverflowAction.VaultAction.ViewClick) {
sendEvent(VaultEvent.NavigateToVaultItem(action.cipherId))
}
private fun handleInternalAction(action: VaultAction.Internal) {
when (action) {
is VaultAction.Internal.PullToRefreshEnableReceive -> {
@ -584,6 +664,11 @@ data class VaultState(
*/
abstract val supportingLabel: Text?
/**
* The overflow options to be displayed for the vault item.
*/
abstract val overflowOptions: List<ListingItemOverflowAction.VaultAction>
/**
* Represents a login item within the vault.
*
@ -594,6 +679,7 @@ data class VaultState(
override val id: String,
override val name: Text,
override val startIcon: IconData = IconData.Local(R.drawable.ic_login_item),
override val overflowOptions: List<ListingItemOverflowAction.VaultAction>,
val username: Text?,
) : VaultItem() {
override val supportingLabel: Text? get() = username
@ -610,6 +696,7 @@ data class VaultState(
override val id: String,
override val name: Text,
override val startIcon: IconData = IconData.Local(R.drawable.ic_card_item),
override val overflowOptions: List<ListingItemOverflowAction.VaultAction>,
val brand: Text? = null,
val lastFourDigits: Text? = null,
) : VaultItem() {
@ -636,6 +723,7 @@ data class VaultState(
override val id: String,
override val name: Text,
override val startIcon: IconData = IconData.Local(R.drawable.ic_identity_item),
override val overflowOptions: List<ListingItemOverflowAction.VaultAction>,
val firstName: Text?,
) : VaultItem() {
override val supportingLabel: Text? get() = firstName
@ -650,6 +738,7 @@ data class VaultState(
override val id: String,
override val name: Text,
override val startIcon: IconData = IconData.Local(R.drawable.ic_secure_note_item),
override val overflowOptions: List<ListingItemOverflowAction.VaultAction>,
) : VaultItem() {
override val supportingLabel: Text? get() = null
}
@ -726,6 +815,13 @@ sealed class VaultEvent {
val itemListingType: VaultItemListingType,
) : VaultEvent()
/**
* Navigates to the given [url].
*/
data class NavigateToUrl(
val url: String,
) : VaultEvent()
/**
* Navigate to the verification code screen.
*/
@ -874,6 +970,13 @@ sealed class VaultAction {
*/
data object TryAgainClick : VaultAction()
/**
* User clicked an overflow action.
*/
data class OverflowOptionClick(
val overflowAction: ListingItemOverflowAction.VaultAction,
) : VaultAction()
/**
* Models actions that the [VaultViewModel] itself might send.
*/

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.handlers
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultAction
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultViewModel
@ -31,6 +32,7 @@ data class VaultHandlers(
val trashClick: () -> Unit,
val tryAgainClick: () -> Unit,
val dialogDismiss: () -> Unit,
val overflowOptionClick: (ListingItemOverflowAction.VaultAction) -> Unit,
) {
companion object {
/**
@ -74,6 +76,9 @@ data class VaultHandlers(
trashClick = { viewModel.trySendAction(VaultAction.TrashClick) },
tryAgainClick = { viewModel.trySendAction(VaultAction.TryAgainClick) },
dialogDismiss = { viewModel.trySendAction(VaultAction.DialogDismiss) },
overflowOptionClick = {
viewModel.trySendAction(VaultAction.OverflowOptionClick(it))
},
)
}
}

View file

@ -10,6 +10,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
@ -156,11 +157,13 @@ private fun CipherView.toVaultItemOrNull(
isIconLoadingDisabled = isIconLoadingDisabled,
baseIconUrl = baseIconUrl,
),
overflowOptions = toOverflowActions(),
)
CipherType.SECURE_NOTE -> VaultState.ViewState.VaultItem.SecureNote(
id = id,
name = name.asText(),
overflowOptions = toOverflowActions(),
)
CipherType.CARD -> VaultState.ViewState.VaultItem.Card(
@ -170,12 +173,14 @@ private fun CipherView.toVaultItemOrNull(
lastFourDigits = card?.number
?.takeLast(4)
?.asText(),
overflowOptions = toOverflowActions(),
)
CipherType.IDENTITY -> VaultState.ViewState.VaultItem.Identity(
id = id,
name = name.asText(),
firstName = identity?.firstName?.asText(),
overflowOptions = toOverflowActions(),
)
}
}

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.core.net.toUri
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
@ -23,6 +24,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
@ -63,6 +65,7 @@ class VaultScreenTest : BaseComposeTest() {
private var onNavigateToVerificationCodeScreen = false
private var onNavigateToSearchScreen = false
private val exitManager = mockk<ExitManager>(relaxed = true)
private val intentManager = mockk<IntentManager>(relaxed = true)
private val mutableEventFlow = bufferedMutableSharedFlow<VaultEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -84,6 +87,7 @@ class VaultScreenTest : BaseComposeTest() {
onNavigateToVerificationCodeScreen = { onNavigateToVerificationCodeScreen = true },
onNavigateToSearchVault = { onNavigateToSearchScreen = true },
exitManager = exitManager,
intentManager = intentManager,
)
}
}
@ -653,6 +657,15 @@ class VaultScreenTest : BaseComposeTest() {
assertEquals(VaultItemListingType.Folder(mockFolderId), onNavigateToVaultItemListingType)
}
@Test
fun `NavigateToUrl event should call launchUri`() {
val url = "www.test.com"
mutableEventFlow.tryEmit(VaultEvent.NavigateToUrl(url))
verify(exactly = 1) {
intentManager.launchUri(url.toUri())
}
}
@Test
fun `NavigateOutOfApp event should call exitApplication on the ExitManager`() {
mutableEventFlow.tryEmit(VaultEvent.NavigateOutOfApp)
@ -722,6 +735,7 @@ class VaultScreenTest : BaseComposeTest() {
id = "12345",
name = itemText.asText(),
username = username.asText(),
overflowOptions = emptyList(),
)
mutableStateFlow.update {
it.copy(
@ -842,6 +856,7 @@ class VaultScreenTest : BaseComposeTest() {
id = "12345",
name = itemText.asText(),
username = userName.asText(),
overflowOptions = emptyList(),
)
mutableStateFlow.update {
it.copy(

View file

@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.model.Environment
@ -19,6 +20,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState
@ -38,6 +40,10 @@ import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class VaultViewModelTest : BaseViewModelTest() {
private val clipboardManager: BitwardenClipboardManager = mockk {
every { setText(any<String>()) } just runs
}
private val mutablePullToRefreshEnabledFlow = MutableStateFlow(false)
private val mutableIsIconLoadingDisabledFlow = MutableStateFlow(false)
@ -1055,8 +1061,133 @@ class VaultViewModelTest : BaseViewModelTest() {
assertTrue(viewModel.stateFlow.value.isIconLoadingDisabled)
}
@Test
fun `OverflowOptionClick Vault CopyNoteClick should call setText on the ClipboardManager`() =
runTest {
val notes = "notes"
val viewModel = createViewModel()
viewModel.actionChannel.trySend(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.CopyNoteClick(notes = notes),
),
)
verify(exactly = 1) {
clipboardManager.setText(notes)
}
}
@Test
fun `OverflowOptionClick Vault CopyNumberClick should call setText on the ClipboardManager`() =
runTest {
val number = "12345-4321-9876-6789"
val viewModel = createViewModel()
viewModel.actionChannel.trySend(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.CopyNumberClick(number = number),
),
)
verify(exactly = 1) {
clipboardManager.setText(number)
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopyPasswordClick should call setText on the ClipboardManager`() =
runTest {
val password = "passTheWord"
val viewModel = createViewModel()
viewModel.actionChannel.trySend(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.CopyPasswordClick(password = password),
),
)
verify(exactly = 1) {
clipboardManager.setText(password)
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopySecurityCodeClick should call setText on the ClipboardManager`() =
runTest {
val securityCode = "234"
val viewModel = createViewModel()
viewModel.actionChannel.trySend(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.CopySecurityCodeClick(
securityCode = securityCode,
),
),
)
verify(exactly = 1) {
clipboardManager.setText(securityCode)
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopyUsernameClick should call setText on the ClipboardManager`() =
runTest {
val username = "bitwarden"
val viewModel = createViewModel()
viewModel.actionChannel.trySend(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.CopyUsernameClick(
username = username,
),
),
)
verify(exactly = 1) {
clipboardManager.setText(username)
}
}
@Test
fun `OverflowOptionClick Vault EditClick should emit NavigateToEditVaultItem`() = runTest {
val cipherId = "cipherId-1234"
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.EditClick(cipherId = cipherId),
),
)
assertEquals(VaultEvent.NavigateToEditVaultItem(cipherId), awaitItem())
}
}
@Test
fun `OverflowOptionClick Vault LaunchClick should emit NavigateToUrl`() = runTest {
val url = "www.test.com"
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.LaunchClick(url = url),
),
)
assertEquals(VaultEvent.NavigateToUrl(url), awaitItem())
}
}
@Test
fun `OverflowOptionClick Vault ViewClick should emit NavigateToUrl`() = runTest {
val cipherId = "cipherId-9876"
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.ViewClick(cipherId = cipherId),
),
)
assertEquals(VaultEvent.NavigateToVaultItem(cipherId), awaitItem())
}
}
private fun createViewModel(): VaultViewModel =
VaultViewModel(
clipboardManager = clipboardManager,
authRepository = authRepository,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,