BIT-920 Implement send screen empty state UI (#191)

This commit is contained in:
Andrew Haisting 2023-11-06 09:03:02 -06:00 committed by Álison Fernandes
parent 8606dce6f5
commit 923380a4f6
9 changed files with 414 additions and 68 deletions

View file

@ -1,27 +0,0 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
/**
* Temporary composable that can be used as a navigation placeholder.
*/
@Composable
fun PlaceholderComposable(
text: String = "Placeholder Composable",
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center,
) {
Text(text = text)
}
}

View file

@ -23,17 +23,14 @@ import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavOptions
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.PlaceholderComposable
import com.x8bit.bitwarden.ui.platform.feature.settings.SETTINGS_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettingsGraph
import com.x8bit.bitwarden.ui.platform.feature.settings.settingsGraph
@ -41,9 +38,11 @@ import com.x8bit.bitwarden.ui.platform.theme.RootTransitionProviders
import com.x8bit.bitwarden.ui.tools.feature.generator.GENERATOR_ROUTE
import com.x8bit.bitwarden.ui.tools.feature.generator.generatorDestination
import com.x8bit.bitwarden.ui.tools.feature.generator.navigateToGenerator
import com.x8bit.bitwarden.ui.tools.feature.send.SEND_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.tools.feature.send.navigateToSend
import com.x8bit.bitwarden.ui.tools.feature.send.sendGraph
import com.x8bit.bitwarden.ui.vault.feature.vault.VAULT_ROUTE
import com.x8bit.bitwarden.ui.vault.feature.vault.navigateToVault
import com.x8bit.bitwarden.ui.vault.feature.vault.navigateToVaultAddItem
import com.x8bit.bitwarden.ui.vault.feature.vault.vaultDestination
import kotlinx.parcelize.Parcelize
@ -190,7 +189,7 @@ private fun VaultUnlockedNavBarScaffold(
navigateToVaultAddItem()
},
)
sendDestination()
sendGraph()
generatorDestination()
settingsGraph(navController)
}
@ -257,7 +256,7 @@ private sealed class VaultUnlockedNavBarTab : Parcelable {
override val iconRes get() = R.drawable.ic_send
override val labelRes get() = R.string.send
override val contentDescriptionRes get() = R.string.send
override val route get() = SEND_ROUTE
override val route get() = SEND_GRAPH_ROUTE
}
/**
@ -296,38 +295,3 @@ private fun NavController.vaultUnlockedNavBarScreenNavOptions(): NavOptions =
launchSingleTop = true
restoreState = true
}
/**
* The functions below should be moved to their respective feature packages once they exist.
*
* For an example of how to setup these nav extensions, see NIA project.
*/
// #region Send
/**
* TODO: move to send package (BIT-149)
*/
private const val SEND_ROUTE = "send"
/**
* Add send destination to the nav graph.
*
* TODO: move to send package (BIT-149)
*/
private fun NavGraphBuilder.sendDestination() {
composable(SEND_ROUTE) {
PlaceholderComposable(text = "Send")
}
}
/**
* Navigate to the send screen. Note this will only work if send screen was added
* via [sendDestination].
*
* TODO: move to send package (BIT-149)
*
*/
private fun NavController.navigateToSend(navOptions: NavOptions? = null) {
navigate(SEND_ROUTE, navOptions)
}
// #endregion Send

View file

@ -0,0 +1,64 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
/**
* Content for the empty state of the [SendScreen].
*/
@Composable
fun SendEmpty(
onAddItemClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.surface),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = stringResource(id = R.string.no_sends),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(24.dp))
Button(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
onClick = onAddItemClick,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(
text = stringResource(id = R.string.add_a_send),
style = MaterialTheme.typography.labelLarge,
)
}
}
}

View file

@ -0,0 +1,28 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.navigation
const val SEND_GRAPH_ROUTE: String = "send_graph"
/**
* Add send destination to the nav graph.
*/
fun NavGraphBuilder.sendGraph() {
navigation(
startDestination = SEND_ROUTE,
route = SEND_GRAPH_ROUTE,
) {
sendDestination()
}
}
/**
* Navigate to the send screen. Note this will only work if send screen was added
* via [sendGraph].
*/
fun NavController.navigateToSendGraph(navOptions: NavOptions? = null) {
navigate(SEND_GRAPH_ROUTE, navOptions)
}

View file

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.x8bit.bitwarden.ui.platform.theme.TransitionProviders
const val SEND_ROUTE: String = "send"
/**
* Add send destination to the nav graph.
*/
fun NavGraphBuilder.sendDestination() {
composable(
route = SEND_ROUTE,
enterTransition = TransitionProviders.Enter.stay,
exitTransition = TransitionProviders.Exit.pushLeft,
popEnterTransition = TransitionProviders.Enter.pushRight,
popExitTransition = TransitionProviders.Exit.fadeOut,
) {
SendScreen()
}
}
/**
* Navigate to the send screen. Note this will only work if send screen was added
* via [sendDestination].
*/
fun NavController.navigateToSend(navOptions: NavOptions? = null) {
navigate(SEND_ROUTE, navOptions)
}

View file

@ -0,0 +1,97 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandIn
import androidx.compose.animation.fadeIn
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
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.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.components.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
/**
* UI for the send screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SendScreen(
viewModel: SendViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is SendEvent.ShowToast -> Toast
.makeText(context, event.messsage(context.resources), Toast.LENGTH_SHORT)
.show()
}
}
Scaffold(
topBar = {
BitwardenMediumTopAppBar(
title = stringResource(id = R.string.send),
scrollBehavior = scrollBehavior,
actions = {
BitwardenSearchActionItem(
contentDescription = stringResource(id = R.string.search_sends),
onClick = remember(viewModel) {
{ viewModel.trySendAction(SendAction.SearchClick) }
},
)
},
)
},
floatingActionButton = {
AnimatedVisibility(
visible = true,
// The enter transition is required for AnimatedVisibility to work correctly on
// FloatingActionButton. See - https://issuetracker.google.com/issues/224005027?pli=1
enter = fadeIn() + expandIn { IntSize(width = 1, height = 1) },
) {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.primaryContainer,
onClick = remember(viewModel) {
{ viewModel.trySendAction(SendAction.AddSendClick) }
},
) {
Icon(
painter = painterResource(id = R.drawable.ic_plus),
contentDescription = stringResource(id = R.string.add_item),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
},
) { padding ->
Box(Modifier.padding(padding)) {
when (state) {
SendState.Empty -> SendEmpty(
remember(viewModel) {
{ viewModel.trySendAction(SendAction.AddSendClick) }
},
)
}
}
}
}

View file

@ -0,0 +1,83 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
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
private const val KEY_STATE = "state"
/**
* View model for the send screen.
*/
@HiltViewModel
class SendViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<SendState, SendEvent, SendAction>(
initialState = savedStateHandle[KEY_STATE] ?: SendState.Empty,
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: SendAction): Unit = when (action) {
SendAction.AddSendClick -> handleSendClick()
SendAction.SearchClick -> handleSearchClick()
}
private fun handleSearchClick() {
// TODO: navigate to send search BIT-594
sendEvent(SendEvent.ShowToast("Search Not Implemented".asText()))
}
private fun handleSendClick() {
// TODO: navigate to new send UI BIT-479
sendEvent(SendEvent.ShowToast("New Send Not Implemented".asText()))
}
}
/**
* Models state for the Send screen.
*/
sealed class SendState : Parcelable {
/**
* Show the empty state.
*/
@Parcelize
data object Empty : SendState()
}
/**
* Models actions for the send screen.
*/
sealed class SendAction {
/**
* User clicked add a send.
*/
data object AddSendClick : SendAction()
/**
* User clicked search button.
*/
data object SearchClick : SendAction()
}
/**
* Models events for the send screen.
*/
sealed class SendEvent {
/**
* Show a toast to the user.
*/
data class ShowToast(val messsage: Text) : SendEvent()
}

View file

@ -0,0 +1,60 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
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
class SendScreenTest : BaseComposeTest() {
private val mutableEventFlow = MutableSharedFlow<SendEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<SendViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
SendScreen(
viewModel = viewModel,
)
}
}
@Test
fun `on add item FAB click should send AddItemClick`() {
composeTestRule
.onNodeWithContentDescription("Add Item")
.performClick()
verify { viewModel.trySendAction(SendAction.AddSendClick) }
}
@Test
fun `on add item click should send AddItemClick`() {
composeTestRule
.onNodeWithText("Add a Send")
.performClick()
verify { viewModel.trySendAction(SendAction.AddSendClick) }
}
@Test
fun `on search click should send SearchClick`() {
composeTestRule
.onNodeWithContentDescription("Search Sends")
.performClick()
verify { viewModel.trySendAction(SendAction.SearchClick) }
}
}
private val DEFAULT_STATE = SendState.Empty

View file

@ -0,0 +1,45 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SendViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be Empty`() {
val viewModel = SendViewModel(SavedStateHandle())
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)
assertEquals(savedState, viewModel.stateFlow.value)
}
@Test
fun `AddSendClick should emit ShowToast`() = runTest {
val viewModel = SendViewModel(SavedStateHandle())
viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.AddSendClick)
assertEquals(SendEvent.ShowToast("New Send Not Implemented".asText()), awaitItem())
}
}
@Test
fun `SearchClick should emit ShowToast`() = runTest {
val viewModel = SendViewModel(SavedStateHandle())
viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.SearchClick)
assertEquals(SendEvent.ShowToast("Search Not Implemented".asText()), awaitItem())
}
}
}