diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenLoadingContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenLoadingContent.kt new file mode 100644 index 000000000..0446a8146 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenLoadingContent.kt @@ -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()) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendContent.kt new file mode 100644 index 000000000..357fbe334 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendContent.kt @@ -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()) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendEmpty.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendEmpty.kt index 26a29f59c..0f5df71ec 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendEmpty.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendEmpty.kt @@ -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()) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt index b21200ff3..40f08b326 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt @@ -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) } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt index 8360956a5..521b73561 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt @@ -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( // 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. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt index dc9f84f51..e70a19239 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt @@ -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, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt index 4b525308f..2da90850d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt @@ -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()), +)