BIT-1158: Add No Network states to Vault Screen (#391)

This commit is contained in:
Brian Yencho 2023-12-14 10:59:26 -06:00 committed by Álison Fernandes
parent 36e913b680
commit 0655f74479
5 changed files with 380 additions and 13 deletions

View file

@ -0,0 +1,49 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.height
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
/**
* A Bitwarden-themed, re-usable error state.
*
* Note that when [onTryAgainClick] is absent, there will be no "Try again" button displayed.
*/
@Composable
fun BitwardenErrorContent(
message: String,
modifier: Modifier = Modifier,
onTryAgainClick: (() -> Unit)? = null,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = message,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
onTryAgainClick?.let {
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextButton(
label = stringResource(id = R.string.try_again),
onClick = it,
)
}
}
}

View file

@ -25,11 +25,15 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
@ -125,6 +129,12 @@ fun VaultScreen(
trashClick = remember(viewModel) { trashClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.TrashClick) } { viewModel.trySendAction(VaultAction.TrashClick) }
}, },
tryAgainClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.TryAgainClick) }
},
dialogDismiss = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.DialogDismiss) }
},
) )
} }
@ -151,6 +161,8 @@ private fun VaultScreenScaffold(
identityGroupClick: () -> Unit, identityGroupClick: () -> Unit,
secureNoteGroupClick: () -> Unit, secureNoteGroupClick: () -> Unit,
trashClick: () -> Unit, trashClick: () -> Unit,
tryAgainClick: () -> Unit,
dialogDismiss: () -> Unit,
) { ) {
var accountMenuVisible by rememberSaveable { var accountMenuVisible by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
@ -165,6 +177,20 @@ private fun VaultScreenScaffold(
canScroll = { !accountMenuVisible }, canScroll = { !accountMenuVisible },
) )
when (val dialog = state.dialog) {
is VaultState.DialogState.Error -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = dialog.title,
message = dialog.message,
),
onDismissRequest = dialogDismiss,
)
}
null -> Unit
}
BitwardenScaffold( BitwardenScaffold(
topBar = { topBar = {
BitwardenMediumTopAppBar( BitwardenMediumTopAppBar(
@ -229,9 +255,15 @@ private fun VaultScreenScaffold(
modifier = modifier, modifier = modifier,
addItemClickAction = addItemClickAction, addItemClickAction = addItemClickAction,
) )
is VaultState.ViewState.Error -> BitwardenErrorContent(
message = viewState.message(),
onTryAgainClick = tryAgainClick,
modifier = modifier
.padding(horizontal = 16.dp),
)
} }
val context = LocalContext.current
BitwardenAccountSwitcher( BitwardenAccountSwitcher(
isVisible = accountMenuVisible, isVisible = accountMenuVisible,
accountSummaries = state.accountSummaries.toImmutableList(), accountSummaries = state.accountSummaries.toImmutableList(),

View file

@ -95,6 +95,8 @@ class VaultViewModel @Inject constructor(
is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick() is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
is VaultAction.TrashClick -> handleTrashClick() is VaultAction.TrashClick -> handleTrashClick()
is VaultAction.VaultItemClick -> handleVaultItemClick(action) is VaultAction.VaultItemClick -> handleVaultItemClick(action)
is VaultAction.TryAgainClick -> handleTryAgainClick()
is VaultAction.DialogDismiss -> handleDialogDismiss()
is VaultAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action) is VaultAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action)
is VaultAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) is VaultAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
} }
@ -176,6 +178,16 @@ class VaultViewModel @Inject constructor(
sendEvent(VaultEvent.NavigateToVaultItem(action.vaultItem.id)) sendEvent(VaultEvent.NavigateToVaultItem(action.vaultItem.id))
} }
private fun handleTryAgainClick() {
vaultRepository.sync()
}
private fun handleDialogDismiss() {
mutableStateFlow.update {
it.copy(dialog = null)
}
}
private fun handleUserStateUpdateReceive(action: VaultAction.Internal.UserStateUpdateReceive) { private fun handleUserStateUpdateReceive(action: VaultAction.Internal.UserStateUpdateReceive) {
// Leave the current data alone if there is no UserState; we are in the process of logging // Leave the current data alone if there is no UserState; we are in the process of logging
// out. // out.
@ -225,9 +237,27 @@ class VaultViewModel @Inject constructor(
} }
private fun vaultNoNetworkReceive(vaultData: DataState.NoNetwork<VaultData>) { private fun vaultNoNetworkReceive(vaultData: DataState.NoNetwork<VaultData>) {
// TODO update state to no network state BIT-1158 val title = R.string.internet_connection_required_title.asText()
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) } val message = R.string.internet_connection_required_message.asText()
sendEvent(VaultEvent.ShowToast(message = "Vault no network state not yet implemented")) if (vaultData.data != null) {
mutableStateFlow.update {
it.copy(
viewState = vaultData.data.toViewState(),
dialog = VaultState.DialogState.Error(
title = title,
message = message,
),
)
}
} else {
mutableStateFlow.update {
it.copy(
viewState = VaultState.ViewState.Error(
message = message,
),
)
}
}
} }
private fun vaultPendingReceive(vaultData: DataState.Pending<VaultData>) { private fun vaultPendingReceive(vaultData: DataState.Pending<VaultData>) {
@ -245,6 +275,7 @@ class VaultViewModel @Inject constructor(
* @property initials The initials to be displayed on the avatar. * @property initials The initials to be displayed on the avatar.
* @property accountSummaries List of all the current accounts. * @property accountSummaries List of all the current accounts.
* @property viewState The specific view state representing loading, no items, or content state. * @property viewState The specific view state representing loading, no items, or content state.
* @property dialog Information about any dialogs that may need to be displayed.
* @property isSwitchingAccounts Whether or not we are actively switching accounts. * @property isSwitchingAccounts Whether or not we are actively switching accounts.
*/ */
@Parcelize @Parcelize
@ -253,6 +284,7 @@ data class VaultState(
val initials: String, val initials: String,
val accountSummaries: List<AccountSummary>, val accountSummaries: List<AccountSummary>,
val viewState: ViewState, val viewState: ViewState,
val dialog: DialogState? = null,
// Internal-use properties // Internal-use properties
val isSwitchingAccounts: Boolean = false, val isSwitchingAccounts: Boolean = false,
) : Parcelable { ) : Parcelable {
@ -280,6 +312,15 @@ data class VaultState(
@Parcelize @Parcelize
data object NoItems : ViewState() data object NoItems : ViewState()
/**
* Represents a state where the [VaultScreen] is unable to display data due to an error
* retrieving it. The given [message] should be displayed.
*/
@Parcelize
data class Error(
val message: Text,
) : ViewState()
/** /**
* Content state for the [VaultScreen] showing the actual content or items. * Content state for the [VaultScreen] showing the actual content or items.
* *
@ -433,6 +474,21 @@ data class VaultState(
} }
} }
/**
* Information about a dialog to display.
*/
sealed class DialogState : Parcelable {
/**
* Represents an error dialog with the given [title] and [message].
*/
@Parcelize
data class Error(
val title: Text,
val message: Text,
) : DialogState()
}
companion object { companion object {
/** /**
* The maximum number of no folder items that can be displayed before the UI creates a * The maximum number of no folder items that can be displayed before the UI creates a
@ -572,6 +628,16 @@ sealed class VaultAction {
*/ */
data object TrashClick : VaultAction() data object TrashClick : VaultAction()
/**
* The user has requested that any visible dialogs are dismissed.
*/
data object DialogDismiss : VaultAction()
/**
* User clicked the Try Again button when there is an error displayed.
*/
data object TryAgainClick : VaultAction()
/** /**
* Models actions that the [VaultViewModel] itself might send. * Models actions that the [VaultViewModel] itself might send.
*/ */

View file

@ -3,9 +3,12 @@ package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasScrollToNodeAction import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
@ -176,6 +179,104 @@ class VaultScreenTest : BaseComposeTest() {
composeTestRule.assertNoDialogExists() composeTestRule.assertNoDialogExists()
} }
@Test
fun `error dialog should be shown or hidden according to the state`() {
val errorTitle = "Error title"
val errorMessage = "Error message"
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText(errorTitle)
.assertDoesNotExist()
composeTestRule
.onNodeWithText(errorMessage)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.Error(
title = errorTitle.asText(),
message = errorMessage.asText(),
),
)
}
composeTestRule
.onAllNodesWithText(errorTitle)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(errorMessage)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `OK button click in error dialog should send DialogDismiss`() {
val errorTitle = "Error title"
val errorMessage = "Error message"
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.Error(
title = errorTitle.asText(),
message = errorMessage.asText(),
),
)
}
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(VaultAction.DialogDismiss) }
}
@Test
fun `Error screen should be shown according to the state`() {
val errorMessage = "Error message"
val tryAgainButtonText = "Try again"
composeTestRule
.onNodeWithText(errorMessage)
.assertDoesNotExist()
composeTestRule
.onNodeWithText(tryAgainButtonText)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = VaultState.ViewState.Error(
message = errorMessage.asText(),
),
)
}
composeTestRule
.onNodeWithText(errorMessage)
.assertIsDisplayed()
composeTestRule
.onNodeWithText(tryAgainButtonText)
.assertIsDisplayed()
}
@Test
fun `try again button click on the Error screen should send TryAgainClick`() {
val errorMessage = "Error message"
val tryAgainButtonText = "Try again"
mutableStateFlow.update {
it.copy(
viewState = VaultState.ViewState.Error(
message = errorMessage.asText(),
),
)
}
composeTestRule
.onNodeWithText(tryAgainButtonText)
.performClick()
verify { viewModel.trySendAction(VaultAction.TryAgainClick) }
}
@Test @Test
fun `search icon click should send SearchIconClick action`() { fun `search icon click should send SearchIconClick action`() {
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) } mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) }

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.vault package com.x8bit.bitwarden.ui.vault.feature.vault
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
@ -352,21 +353,95 @@ class VaultViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `vaultDataStateFlow NoNetwork should show toast and update state to NoItems`() = runTest { fun `vaultDataStateFlow NoNetwork without data should update state to Error`() = runTest {
mutableVaultDataStateFlow.tryEmit( mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(), value = DataState.NoNetwork(),
) )
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.eventFlow.test {
assertEquals( assertEquals(
VaultEvent.ShowToast("Vault no network state not yet implemented"), createMockVaultState(
awaitItem(), viewState = VaultState.ViewState.Error(
message = R.string.internet_connection_required_message.asText(),
),
),
viewModel.stateFlow.value,
) )
} }
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow NoNetwork with items should update state to Content and show an error dialog`() =
runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
)
val viewModel = createViewModel()
assertEquals( assertEquals(
createMockVaultState(viewState = VaultState.ViewState.NoItems), createMockVaultState(
viewState = VaultState.ViewState.Content(
loginItemsCount = 1,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = listOf(),
folderItems = listOf(
VaultState.ViewState.FolderItem(
id = "mockId-1",
name = "mockName-1".asText(),
itemCount = 1,
),
),
collectionItems = listOf(
VaultState.ViewState.CollectionItem(
id = "mockId-1",
name = "mockName-1",
itemCount = 1,
),
),
noFolderItems = listOf(),
trashItemsCount = 0,
),
dialog = VaultState.DialogState.Error(
title = R.string.internet_connection_required_title.asText(),
message = R.string.internet_connection_required_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow NoNetwork with empty items should update state to NoItems and show an error dialog`() =
runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(
data = VaultData(
cipherViewList = emptyList(),
collectionViewList = emptyList(),
folderViewList = emptyList(),
),
),
)
val viewModel = createViewModel()
assertEquals(
createMockVaultState(
viewState = VaultState.ViewState.NoItems,
dialog = VaultState.DialogState.Error(
title = R.string.internet_connection_required_title.asText(),
message = R.string.internet_connection_required_message.asText(),
),
),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
} }
@ -532,6 +607,46 @@ class VaultViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `TryAgainClick should sync the vault data`() {
val viewModel = createViewModel()
viewModel.trySendAction(VaultAction.TryAgainClick)
verify { vaultRepository.sync() }
}
@Test
fun `DialogDismiss should clear the active dialog`() {
// Show the No Network error dialog
val viewModel = createViewModel()
mutableVaultDataStateFlow.value = DataState.NoNetwork(
data = VaultData(
cipherViewList = emptyList(),
collectionViewList = emptyList(),
folderViewList = emptyList(),
),
)
val initialState = DEFAULT_STATE.copy(
viewState = VaultState.ViewState.NoItems,
dialog = VaultState.DialogState.Error(
title = R.string.internet_connection_required_title.asText(),
message = R.string.internet_connection_required_message.asText(),
),
)
assertEquals(
initialState,
viewModel.stateFlow.value,
)
viewModel.trySendAction(VaultAction.DialogDismiss)
assertEquals(
initialState.copy(dialog = null),
viewModel.stateFlow.value,
)
}
private fun createViewModel(): VaultViewModel = private fun createViewModel(): VaultViewModel =
VaultViewModel( VaultViewModel(
authRepository = authRepository, authRepository = authRepository,
@ -566,7 +681,10 @@ private val DEFAULT_USER_STATE = UserState(
), ),
) )
private fun createMockVaultState(viewState: VaultState.ViewState): VaultState = private fun createMockVaultState(
viewState: VaultState.ViewState,
dialog: VaultState.DialogState? = null,
): VaultState =
VaultState( VaultState(
avatarColorString = "#aa00aa", avatarColorString = "#aa00aa",
initials = "AU", initials = "AU",
@ -591,5 +709,6 @@ private fun createMockVaultState(viewState: VaultState.ViewState): VaultState =
), ),
), ),
viewState = viewState, viewState = viewState,
dialog = dialog,
isSwitchingAccounts = false, isSwitchingAccounts = false,
) )