mirror of
https://github.com/bitwarden/android.git
synced 2025-03-16 03:08:50 +03:00
BIT-920 Implement send screen empty state UI (#191)
This commit is contained in:
parent
8606dce6f5
commit
923380a4f6
9 changed files with 414 additions and 68 deletions
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue