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,
+    )
 }