Add the overflow menu to the send screen (#469)

This commit is contained in:
David Perez 2024-01-02 13:34:35 -06:00 committed by Álison Fernandes
parent cf2e87c9f5
commit 7a8da67944
4 changed files with 223 additions and 28 deletions

View file

@ -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) {

View file

@ -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()
}

View file

@ -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

View file

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