Add basic UI states to the SendScreen (#471)

This commit is contained in:
David Perez 2024-01-02 15:30:37 -06:00 committed by Álison Fernandes
parent 0c05855e6b
commit da365acfee
7 changed files with 270 additions and 27 deletions

View file

@ -0,0 +1,27 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
/**
* A Bitwarden-themed, re-usable loading state.
*/
@Composable
fun BitwardenLoadingContent(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.navigationBarsPadding())
}
}

View file

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Content view for the [SendScreen].
*/
@Composable
fun SendContent(
state: SendState.ViewState.Content,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
item {
Spacer(modifier = Modifier.height(88.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}

View file

@ -3,9 +3,9 @@ package com.x8bit.bitwarden.ui.tools.feature.send
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -25,10 +25,10 @@ import com.x8bit.bitwarden.R
@Composable
fun SendEmpty(
onAddItemClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = Modifier
.fillMaxSize(),
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -58,5 +58,6 @@ fun SendEmpty(
style = MaterialTheme.typography.labelLarge,
)
}
Spacer(modifier = Modifier.navigationBarsPadding())
}
}

View file

@ -2,9 +2,10 @@ 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.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
@ -20,13 +21,14 @@ 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.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
@ -106,10 +108,9 @@ fun SendScreen(
},
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) },
visible = state.viewState.shouldDisplayFab,
enter = scaleIn(),
exit = scaleOut(),
) {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.primaryContainer,
@ -126,14 +127,32 @@ fun SendScreen(
}
},
) { padding ->
Box(modifier = Modifier.padding(padding)) {
when (state) {
SendState.Empty -> SendEmpty(
remember(viewModel) {
{ viewModel.trySendAction(SendAction.AddSendClick) }
},
)
}
val modifier = Modifier
.imePadding()
.fillMaxSize()
.padding(padding)
when (val viewState = state.viewState) {
is SendState.ViewState.Content -> SendContent(
modifier = modifier,
state = viewState,
)
SendState.ViewState.Empty -> SendEmpty(
modifier = modifier,
onAddItemClick = remember(viewModel) {
{ viewModel.trySendAction(SendAction.AddSendClick) }
},
)
is SendState.ViewState.Error -> BitwardenErrorContent(
modifier = modifier,
message = viewState.message(),
onTryAgainClick = remember(viewModel) {
{ viewModel.trySendAction(SendAction.RefreshClick) }
},
)
SendState.ViewState.Loading -> BitwardenLoadingContent(modifier = modifier)
}
}
}

View file

@ -2,11 +2,16 @@ 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 com.x8bit.bitwarden.ui.vault.feature.item.VaultItemScreen
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -21,12 +26,25 @@ class SendViewModel @Inject constructor(
private val vaultRepo: VaultRepository,
) : BaseViewModel<SendState, SendEvent, SendAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: SendState.Empty,
initialState = savedStateHandle[KEY_STATE]
?: SendState(
viewState = SendState.ViewState.Loading,
),
) {
init {
// TODO: Remove this once we start listening to real vault data BIT-481
viewModelScope.launch {
delay(timeMillis = 3_000L)
mutableStateFlow.update { it.copy(viewState = SendState.ViewState.Empty) }
}
}
override fun handleAction(action: SendAction): Unit = when (action) {
SendAction.AboutSendClick -> handleAboutSendClick()
SendAction.AddSendClick -> handleAddSendClick()
SendAction.LockClick -> handleLockClick()
SendAction.RefreshClick -> handleRefreshClick()
SendAction.SearchClick -> handleSearchClick()
SendAction.SyncClick -> handleSyncClick()
}
@ -43,6 +61,11 @@ class SendViewModel @Inject constructor(
vaultRepo.lockVaultForCurrentUser()
}
private fun handleRefreshClick() {
// No need to update the view state, the vault repo will emit a new state during this time.
vaultRepo.sync()
}
private fun handleSearchClick() {
// TODO: navigate to send search BIT-594
sendEvent(SendEvent.ShowToast("Search Not Implemented".asText()))
@ -57,12 +80,55 @@ class SendViewModel @Inject constructor(
/**
* Models state for the Send screen.
*/
sealed class SendState : Parcelable {
@Parcelize
data class SendState(
val viewState: ViewState,
) : Parcelable {
/**
* Show the empty state.
* Represents the specific view states for the send screen.
*/
@Parcelize
data object Empty : SendState()
sealed class ViewState : Parcelable {
/**
* Indicates if the FAB should be displayed.
*/
abstract val shouldDisplayFab: Boolean
/**
* Show the empty state.
*/
@Parcelize
// TODO: Add actual content BIT-481
data object Content : ViewState() {
override val shouldDisplayFab: Boolean get() = true
}
/**
* Show the empty state.
*/
@Parcelize
data object Empty : ViewState() {
override val shouldDisplayFab: Boolean get() = true
}
/**
* Represents an error state for the [VaultItemScreen].
*/
@Parcelize
data class Error(
val message: Text,
) : ViewState() {
override val shouldDisplayFab: Boolean get() = false
}
/**
* Show the loading state.
*/
@Parcelize
data object Loading : ViewState() {
override val shouldDisplayFab: Boolean get() = false
}
}
}
/**
@ -84,6 +150,11 @@ sealed class SendAction {
*/
data object LockClick : SendAction()
/**
* User clicked the refresh button.
*/
data object RefreshClick : SendAction()
/**
* User clicked search button.
*/

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDisplayed
@ -11,10 +12,13 @@ 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 com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.util.isProgressBar
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
@ -109,8 +113,34 @@ class SendScreenTest : BaseComposeTest() {
}
}
@Test
fun `fab should be displayed according to state`() {
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Loading)
}
composeTestRule.onNodeWithContentDescription("Add item").assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Empty)
}
composeTestRule.onNodeWithContentDescription("Add item").assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Error("Fail".asText()))
}
composeTestRule.onNodeWithContentDescription("Add item").assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Content)
}
composeTestRule.onNodeWithContentDescription("Add item").assertIsDisplayed()
}
@Test
fun `on add item FAB click should send AddItemClick`() {
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Empty)
}
composeTestRule
.onNodeWithContentDescription("Add item")
.performClick()
@ -119,6 +149,9 @@ class SendScreenTest : BaseComposeTest() {
@Test
fun `on add item click should send AddItemClick`() {
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Empty)
}
composeTestRule
.onNodeWithText("Add a Send")
.performClick()
@ -134,10 +167,57 @@ class SendScreenTest : BaseComposeTest() {
}
@Test
fun `on NavigateToNewSend should call onNavgiateToNewSend`() {
fun `on NavigateToNewSend should call onNavigateToNewSend`() {
mutableEventFlow.tryEmit(SendEvent.NavigateNewSend)
assert(onNavigateToNewSendCalled)
}
@Test
fun `progressbar should be displayed according to state`() {
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Loading)
}
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Empty)
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Error("Fail".asText()))
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Content)
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
}
@Test
fun `error should be displayed according to state`() {
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Error("Fail".asText()))
}
composeTestRule.onNodeWithText("Fail").assertIsDisplayed()
composeTestRule.onNodeWithText("Try again").assertIsDisplayed()
}
@Test
fun `on try again click should send send RefreshClick`() {
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Error("Fail".asText()))
}
composeTestRule.onNodeWithText("Try again").performClick()
verify {
viewModel.trySendAction(SendAction.RefreshClick)
}
}
}
private val DEFAULT_STATE = SendState.Empty
private val DEFAULT_STATE: SendState = SendState(
viewState = SendState.ViewState.Loading,
)

View file

@ -21,7 +21,7 @@ class SendViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be Empty`() {
val viewModel = createViewModel()
assertEquals(SendState.Empty, viewModel.stateFlow.value)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
@ -61,6 +61,18 @@ class SendViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `RefreshClick should call sync`() {
val viewModel = createViewModel()
every { vaultRepo.sync() } just runs
viewModel.trySendAction(SendAction.RefreshClick)
verify {
vaultRepo.sync()
}
}
@Test
fun `SearchClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
@ -92,3 +104,11 @@ class SendViewModelTest : BaseViewModelTest() {
vaultRepo = vaultRepository,
)
}
private val DEFAULT_STATE: SendState = SendState(
viewState = SendState.ViewState.Loading,
)
private val DEFAULT_ERROR_STATE: SendState = DEFAULT_STATE.copy(
viewState = SendState.ViewState.Error("Fail".asText()),
)