BIT-379, BIT-381, BIT-384: Add overflow menu to Vault Screen (#397)

This commit is contained in:
Brian Yencho 2023-12-15 09:21:19 -06:00 committed by Álison Fernandes
parent 9a05b7168e
commit 7fa42e5fb8
5 changed files with 240 additions and 1 deletions

View file

@ -4,12 +4,26 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* A utility class for simplifying the handling of Android Intents within a given context.
*/
@OmitFromCoverage
class IntentHandler(private val context: Context) {
/**
* Starts an intent to exit the application.
*/
fun exitApplication() {
// Note that we fire an explicit Intent rather than try to cast to an Activity and call
// finish to avoid assumptions about what kind of context we have.
val intent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
}
startActivity(intent)
}
/**
* Start an activity using the provided [Intent].
*/

View file

@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
@ -38,8 +39,11 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
@ -54,6 +58,7 @@ fun VaultScreen(
onNavigateToVaultEditItemScreen: (vaultItemId: String) -> Unit,
onNavigateToVaultItemListingScreen: (vaultItemType: VaultItemListingType) -> Unit,
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
intentHandler: IntentHandler = IntentHandler(LocalContext.current),
) {
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
@ -75,6 +80,7 @@ fun VaultScreen(
onNavigateToVaultItemListingScreen(event.itemListingType)
}
VaultEvent.NavigateOutOfApp -> intentHandler.exitApplication()
is VaultEvent.ShowToast -> {
Toast
.makeText(context, event.message, Toast.LENGTH_SHORT)
@ -102,6 +108,15 @@ fun VaultScreen(
addAccountClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.AddAccountClick) }
},
syncAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.SyncClick) }
},
lockAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.LockClick) }
},
exitConfirmationAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.ExitConfirmationClick) }
},
onDimBottomNavBarRequest = onDimBottomNavBarRequest,
vaultItemClick = remember(viewModel) {
{ vaultItem -> viewModel.trySendAction(VaultAction.VaultItemClick(vaultItem)) }
@ -152,6 +167,9 @@ private fun VaultScreenScaffold(
accountLogoutClickAction: (AccountSummary) -> Unit,
accountSwitchClickAction: (AccountSummary) -> Unit,
addAccountClickAction: () -> Unit,
syncAction: () -> Unit,
lockAction: () -> Unit,
exitConfirmationAction: () -> Unit,
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
vaultItemClick: (VaultState.ViewState.VaultItem) -> Unit,
folderClick: (VaultState.ViewState.FolderItem) -> Unit,
@ -171,12 +189,14 @@ private fun VaultScreenScaffold(
accountMenuVisible = shouldShowMenu
onDimBottomNavBarRequest(shouldShowMenu)
}
var shouldShowExitConfirmationDialog by rememberSaveable { mutableStateOf(false) }
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
state = rememberTopAppBarState(),
canScroll = { !accountMenuVisible },
)
// Dynamic dialogs
when (val dialog = state.dialog) {
is VaultState.DialogState.Error -> {
BitwardenBasicDialog(
@ -191,6 +211,22 @@ private fun VaultScreenScaffold(
null -> Unit
}
// Static dialogs
if (shouldShowExitConfirmationDialog) {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.exit),
message = stringResource(id = R.string.exit_confirmation),
confirmButtonText = stringResource(id = R.string.yes),
dismissButtonText = stringResource(id = R.string.cancel),
onConfirmClick = {
shouldShowExitConfirmationDialog = false
exitConfirmationAction()
},
onDismissClick = { shouldShowExitConfirmationDialog = false },
onDismissRequest = { shouldShowExitConfirmationDialog = false },
)
}
BitwardenScaffold(
topBar = {
BitwardenMediumTopAppBar(
@ -208,7 +244,22 @@ private fun VaultScreenScaffold(
contentDescription = stringResource(id = R.string.search_vault),
onClick = searchIconClickAction,
)
BitwardenOverflowActionItem()
BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
OverflowMenuItemData(
text = stringResource(id = R.string.sync),
onClick = syncAction,
),
OverflowMenuItemData(
text = stringResource(id = R.string.lock),
onClick = lockAction,
),
OverflowMenuItemData(
text = stringResource(id = R.string.exit),
onClick = { shouldShowExitConfirmationDialog = true },
),
),
)
},
)
},

View file

@ -93,6 +93,9 @@ class VaultViewModel @Inject constructor(
is VaultAction.LogoutAccountClick -> handleLogoutAccountClick(action)
is VaultAction.SwitchAccountClick -> handleSwitchAccountClick(action)
is VaultAction.AddAccountClick -> handleAddAccountClick()
is VaultAction.SyncClick -> handleSyncClick()
is VaultAction.LockClick -> handleLockClick()
is VaultAction.ExitConfirmationClick -> handleExitConfirmationClick()
is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
is VaultAction.TrashClick -> handleTrashClick()
is VaultAction.VaultItemClick -> handleVaultItemClick(action)
@ -168,6 +171,18 @@ class VaultViewModel @Inject constructor(
authRepository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition
}
private fun handleSyncClick() {
vaultRepository.sync()
}
private fun handleLockClick() {
vaultRepository.lockVaultForCurrentUser()
}
private fun handleExitConfirmationClick() {
sendEvent(VaultEvent.NavigateOutOfApp)
}
private fun handleTrashClick() {
sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.Trash))
}
@ -522,6 +537,11 @@ sealed class VaultEvent {
val itemListingType: VaultItemListingType,
) : VaultEvent()
/**
* Navigate out of the app.
*/
data object NavigateOutOfApp : VaultEvent()
/**
* Show a toast with the given [message].
*/
@ -571,6 +591,22 @@ sealed class VaultAction {
*/
data object AddAccountClick : VaultAction()
/**
* User clicked the Sync option in the overflow menu.
*/
data object SyncClick : VaultAction()
/**
* User clicked the Lock option in the overflow menu.
*/
data object LockClick : VaultAction()
/**
* User confirmed that they want to exit the app after clicking the Sync option in the overflow
* menu.
*/
data object ExitConfirmationClick : VaultAction()
/**
* Action to trigger when a specific vault item is clicked.
*/

View file

@ -8,12 +8,14 @@ import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isPopup
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
@ -44,6 +46,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@Suppress("LargeClass")
class VaultScreenTest : BaseComposeTest() {
private var onNavigateToVaultAddItemScreenCalled = false
@ -51,6 +54,7 @@ class VaultScreenTest : BaseComposeTest() {
private var onNavigateToVaultEditItemId: String? = null
private var onNavigateToVaultItemListingType: VaultItemListingType? = null
private var onDimBottomNavBarRequestCalled = false
private val intentHandler = mockk<IntentHandler>(relaxed = true)
private val mutableEventFlow = MutableSharedFlow<VaultEvent>(
extraBufferCapacity = Int.MAX_VALUE,
@ -71,6 +75,7 @@ class VaultScreenTest : BaseComposeTest() {
onNavigateToVaultEditItemScreen = { onNavigateToVaultEditItemId = it },
onNavigateToVaultItemListingScreen = { onNavigateToVaultItemListingType = it },
onDimBottomNavBarRequest = { onDimBottomNavBarRequestCalled = true },
intentHandler = intentHandler,
)
}
}
@ -179,6 +184,105 @@ class VaultScreenTest : BaseComposeTest() {
composeTestRule.assertNoDialogExists()
}
@Test
fun `overflow button click should show the overflow menu`() {
composeTestRule.onNode(isPopup()).assertDoesNotExist()
composeTestRule.onNodeWithText("Sync").assertDoesNotExist()
composeTestRule.onNodeWithText("Lock").assertDoesNotExist()
composeTestRule.onNodeWithText("Exit").assertDoesNotExist()
composeTestRule.onNodeWithContentDescription("More").performClick()
composeTestRule.onNode(isPopup()).assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Sync")
.filterToOne(hasAnyAncestor(isPopup()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Lock")
.filterToOne(hasAnyAncestor(isPopup()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Exit")
.filterToOne(hasAnyAncestor(isPopup()))
.assertIsDisplayed()
}
@Test
fun `sync click in the overflow menu should send SyncClick`() {
// Expand the overflow menu
composeTestRule.onNodeWithContentDescription("More").performClick()
composeTestRule
.onAllNodesWithText("Sync")
.filterToOne(hasAnyAncestor(isPopup()))
.performClick()
verify { viewModel.trySendAction(VaultAction.SyncClick) }
}
@Test
fun `lock click in the overflow menu should send LockClick`() {
// Expand the overflow menu
composeTestRule.onNodeWithContentDescription("More").performClick()
composeTestRule
.onAllNodesWithText("Lock")
.filterToOne(hasAnyAncestor(isPopup()))
.performClick()
verify { viewModel.trySendAction(VaultAction.LockClick) }
}
@Test
fun `exit click in the overflow menu should show a confirmation dialog`() {
// Expand the overflow menu
composeTestRule.onNodeWithContentDescription("More").performClick()
composeTestRule
.onAllNodesWithText("Exit")
.filterToOne(hasAnyAncestor(isPopup()))
.performClick()
composeTestRule
.onNode(isDialog())
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Exit")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Are you sure you want to exit Bitwarden?")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `yes click in exit confirmation dialog should send ExitConfirmationClick`() {
// Expand the overflow menu and show the exit confirmation dialog
composeTestRule.onNodeWithContentDescription("More").performClick()
composeTestRule
.onAllNodesWithText("Exit")
.filterToOne(hasAnyAncestor(isPopup()))
.performClick()
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
verify { viewModel.trySendAction(VaultAction.ExitConfirmationClick) }
}
@Test
fun `error dialog should be shown or hidden according to the state`() {
val errorTitle = "Error title"
@ -363,6 +467,12 @@ class VaultScreenTest : BaseComposeTest() {
assertEquals(VaultItemListingType.Folder(mockFolderId), onNavigateToVaultItemListingType)
}
@Test
fun `NavigateOutOfApp event should call exitApplication on the IntentHandler`() {
mutableEventFlow.tryEmit(VaultEvent.NavigateOutOfApp)
verify { intentHandler.exitApplication() }
}
@Test
fun `clicking a favorite item should send VaultItemClick with the correct item`() {
val itemText = "Test Item"

View file

@ -51,6 +51,7 @@ class VaultViewModelTest : BaseViewModelTest() {
mockk {
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
every { sync() } returns Unit
every { lockVaultForCurrentUser() } just runs
every { lockVaultIfNecessary(any()) } just runs
}
@ -257,6 +258,33 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on SyncClick should call sync on the VaultRepository`() {
val viewModel = createViewModel()
viewModel.trySendAction(VaultAction.SyncClick)
verify {
vaultRepository.sync()
}
}
@Test
fun `on LockClick should call lockVaultForCurrentUser on the VaultRepository`() {
val viewModel = createViewModel()
viewModel.trySendAction(VaultAction.LockClick)
verify {
vaultRepository.lockVaultForCurrentUser()
}
}
@Test
fun `on ExitConfirmationClick should emit NavigateOutOfApp`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.ExitConfirmationClick)
assertEquals(VaultEvent.NavigateOutOfApp, awaitItem())
}
}
@Test
fun `vaultDataStateFlow Loaded with items should update state to Content`() = runTest {
mutableVaultDataStateFlow.tryEmit(