mirror of
https://github.com/bitwarden/android.git
synced 2024-11-22 09:25:58 +03:00
BIT-379, BIT-381, BIT-384: Add overflow menu to Vault Screen (#397)
This commit is contained in:
parent
9a05b7168e
commit
7fa42e5fb8
5 changed files with 240 additions and 1 deletions
|
@ -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].
|
||||
*/
|
||||
|
|
|
@ -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 },
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue