mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
Add basic UI states to the SendScreen (#471)
This commit is contained in:
parent
0c05855e6b
commit
da365acfee
7 changed files with 270 additions and 27 deletions
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
* Represents the specific view states for the send screen.
|
||||
*/
|
||||
sealed class ViewState : Parcelable {
|
||||
/**
|
||||
* Indicates if the FAB should be displayed.
|
||||
*/
|
||||
abstract val shouldDisplayFab: Boolean
|
||||
|
||||
/**
|
||||
* Show the empty state.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Empty : SendState()
|
||||
// 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.
|
||||
*/
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = SendState.Empty
|
||||
@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 = SendState(
|
||||
viewState = SendState.ViewState.Loading,
|
||||
)
|
||||
|
|
|
@ -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()),
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue