From 7a8da67944372c0332b2d72aacbc81f2ab4d5601 Mon Sep 17 00:00:00 2001 From: David Perez <david@livefront.com> Date: Tue, 2 Jan 2024 13:34:35 -0600 Subject: [PATCH] Add the overflow menu to the send screen (#469) --- .../ui/tools/feature/send/SendScreen.kt | 52 ++++++++++-- .../ui/tools/feature/send/SendViewModel.kt | 57 ++++++++++--- .../ui/tools/feature/send/SendScreenTest.kt | 83 ++++++++++++++++++- .../tools/feature/send/SendViewModelTest.kt | 59 +++++++++++-- 4 files changed, 223 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt index 7daf9b7cc..b21200ff3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt @@ -16,40 +16,58 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntSize +import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.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.OverflowMenuItemData +import kotlinx.collections.immutable.persistentListOf /** * UI for the send screen. */ +@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun SendScreen( onNavigateNewSend: () -> Unit, viewModel: SendViewModel = hiltViewModel(), + intentHandler: IntentHandler = IntentHandler(context = LocalContext.current), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val scrollBehavior = - TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val context = LocalContext.current EventsEffect(viewModel = viewModel) { event -> when (event) { is SendEvent.NavigateNewSend -> onNavigateNewSend() - is SendEvent.ShowToast -> Toast - .makeText(context, event.messsage(context.resources), Toast.LENGTH_SHORT) - .show() + + is SendEvent.NavigateToAboutSend -> { + intentHandler.launchUri("https://bitwarden.com/products/send".toUri()) + } + + is SendEvent.ShowToast -> { + Toast + .makeText(context, event.message(context.resources), Toast.LENGTH_SHORT) + .show() + } } } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + state = rememberTopAppBarState(), + ) BitwardenScaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { BitwardenMediumTopAppBar( title = stringResource(id = R.string.send), @@ -61,6 +79,28 @@ fun SendScreen( { viewModel.trySendAction(SendAction.SearchClick) } }, ) + BitwardenOverflowActionItem( + menuItemDataList = persistentListOf( + OverflowMenuItemData( + text = stringResource(id = R.string.sync), + onClick = remember(viewModel) { + { viewModel.trySendAction(SendAction.SyncClick) } + }, + ), + OverflowMenuItemData( + text = stringResource(id = R.string.lock), + onClick = remember(viewModel) { + { viewModel.trySendAction(SendAction.LockClick) } + }, + ), + OverflowMenuItemData( + text = stringResource(id = R.string.about_send), + onClick = remember(viewModel) { + { viewModel.trySendAction(SendAction.AboutSendClick) } + }, + ), + ), + ) }, ) }, @@ -86,7 +126,7 @@ fun SendScreen( } }, ) { padding -> - Box(Modifier.padding(padding)) { + Box(modifier = Modifier.padding(padding)) { when (state) { SendState.Empty -> SendEmpty( remember(viewModel) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt index 4d62293a3..8360956a5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt @@ -2,13 +2,11 @@ package com.x8bit.bitwarden.ui.tools.feature.send import android.os.Parcelable import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -20,19 +18,29 @@ private const val KEY_STATE = "state" @HiltViewModel class SendViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val vaultRepo: VaultRepository, ) : BaseViewModel<SendState, SendEvent, SendAction>( + // We load the state from the savedStateHandle for testing purposes. initialState = savedStateHandle[KEY_STATE] ?: SendState.Empty, ) { - - init { - stateFlow - .onEach { savedStateHandle[KEY_STATE] = it } - .launchIn(viewModelScope) + override fun handleAction(action: SendAction): Unit = when (action) { + SendAction.AboutSendClick -> handleAboutSendClick() + SendAction.AddSendClick -> handleAddSendClick() + SendAction.LockClick -> handleLockClick() + SendAction.SearchClick -> handleSearchClick() + SendAction.SyncClick -> handleSyncClick() } - override fun handleAction(action: SendAction): Unit = when (action) { - SendAction.AddSendClick -> handleSendClick() - SendAction.SearchClick -> handleSearchClick() + private fun handleAboutSendClick() { + sendEvent(SendEvent.NavigateToAboutSend) + } + + private fun handleAddSendClick() { + sendEvent(SendEvent.NavigateNewSend) + } + + private fun handleLockClick() { + vaultRepo.lockVaultForCurrentUser() } private fun handleSearchClick() { @@ -40,7 +48,10 @@ class SendViewModel @Inject constructor( sendEvent(SendEvent.ShowToast("Search Not Implemented".asText())) } - private fun handleSendClick() = sendEvent(SendEvent.NavigateNewSend) + private fun handleSyncClick() { + // TODO: Add loading dialog state BIT-481 + vaultRepo.sync() + } } /** @@ -58,15 +69,30 @@ sealed class SendState : Parcelable { * Models actions for the send screen. */ sealed class SendAction { + /** + * User clicked the about send button. + */ + data object AboutSendClick : SendAction() + /** * User clicked add a send. */ data object AddSendClick : SendAction() + /** + * User clicked the lock button. + */ + data object LockClick : SendAction() + /** * User clicked search button. */ data object SearchClick : SendAction() + + /** + * User clicked the sync button. + */ + data object SyncClick : SendAction() } /** @@ -78,8 +104,13 @@ sealed class SendEvent { */ data object NavigateNewSend : SendEvent() + /** + * Navigate to the about send screen. + */ + data object NavigateToAboutSend : SendEvent() + /** * Show a toast to the user. */ - data class ShowToast(val messsage: Text) : SendEvent() + data class ShowToast(val message: Text) : SendEvent() } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt index 3a5b54021..dc9f84f51 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt @@ -1,13 +1,19 @@ package com.x8bit.bitwarden.ui.tools.feature.send +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDisplayed +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 com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import io.mockk.every import io.mockk.mockk import io.mockk.verify -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Test @@ -15,9 +21,9 @@ import org.junit.Test class SendScreenTest : BaseComposeTest() { private var onNavigateToNewSendCalled = false - private val mutableEventFlow = MutableSharedFlow<SendEvent>( - extraBufferCapacity = Int.MAX_VALUE, - ) + + private val intentHandler = mockk<IntentHandler>() + private val mutableEventFlow = bufferedMutableSharedFlow<SendEvent>() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk<SendViewModel>(relaxed = true) { every { eventFlow } returns mutableEventFlow @@ -30,10 +36,79 @@ class SendScreenTest : BaseComposeTest() { SendScreen( viewModel = viewModel, onNavigateNewSend = { onNavigateToNewSendCalled = true }, + intentHandler = intentHandler, ) } } + @Test + fun `on overflow item click should display menu`() { + composeTestRule + .onNodeWithContentDescription(label = "More") + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Sync") + .filterToOne(hasAnyAncestor(isPopup())) + .isDisplayed() + composeTestRule + .onAllNodesWithText(text = "Lock") + .filterToOne(hasAnyAncestor(isPopup())) + .isDisplayed() + composeTestRule + .onAllNodesWithText(text = "About Send") + .filterToOne(hasAnyAncestor(isPopup())) + .isDisplayed() + } + + @Test + fun `on sync click should send SyncClick`() { + composeTestRule + .onNodeWithContentDescription(label = "More") + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Sync") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + verify { + viewModel.trySendAction(SendAction.SyncClick) + } + } + + @Test + fun `on lock click should send LockClick`() { + composeTestRule + .onNodeWithContentDescription(label = "More") + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Lock") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + verify { + viewModel.trySendAction(SendAction.LockClick) + } + } + + @Test + fun `on about send click should send AboutSendClick`() { + composeTestRule + .onNodeWithContentDescription(label = "More") + .performClick() + + composeTestRule + .onAllNodesWithText(text = "About Send") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + verify { + viewModel.trySendAction(SendAction.AboutSendClick) + } + } + @Test fun `on add item FAB click should send AddItemClick`() { composeTestRule diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt index 04c68d453..4b525308f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt @@ -2,44 +2,93 @@ package com.x8bit.bitwarden.ui.tools.feature.send import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText +import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class SendViewModelTest : BaseViewModelTest() { + private val vaultRepo: VaultRepository = mockk() + @Test fun `initial state should be Empty`() { - val viewModel = SendViewModel(SavedStateHandle()) + val viewModel = createViewModel() assertEquals(SendState.Empty, viewModel.stateFlow.value) } @Test fun `initial state should read from saved state when present`() { val savedState = mockk<SendState>() - val handle = SavedStateHandle(mapOf("state" to savedState)) - val viewModel = SendViewModel(handle) + val viewModel = createViewModel(state = savedState) assertEquals(savedState, viewModel.stateFlow.value) } + @Test + fun `AboutSendClick should emit NavigateToAboutSend`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(SendAction.AboutSendClick) + assertEquals(SendEvent.NavigateToAboutSend, awaitItem()) + } + } + @Test fun `AddSendClick should emit NavigateNewSend`() = runTest { - val viewModel = SendViewModel(SavedStateHandle()) + val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(SendAction.AddSendClick) assertEquals(SendEvent.NavigateNewSend, awaitItem()) } } + @Test + fun `LockClick should lock the vault`() { + val viewModel = createViewModel() + every { vaultRepo.lockVaultForCurrentUser() } just runs + + viewModel.trySendAction(SendAction.LockClick) + + verify { + vaultRepo.lockVaultForCurrentUser() + } + } + @Test fun `SearchClick should emit ShowToast`() = runTest { - val viewModel = SendViewModel(SavedStateHandle()) + val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(SendAction.SearchClick) assertEquals(SendEvent.ShowToast("Search Not Implemented".asText()), awaitItem()) } } + + @Test + fun `SyncClick should call sync`() { + val viewModel = createViewModel() + every { vaultRepo.sync() } just runs + + viewModel.trySendAction(SendAction.SyncClick) + + verify { + vaultRepo.sync() + } + } + + private fun createViewModel( + state: SendState? = null, + vaultRepository: VaultRepository = vaultRepo, + ): SendViewModel = SendViewModel( + savedStateHandle = SavedStateHandle().apply { + set("state", state) + }, + vaultRepo = vaultRepository, + ) }