mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
Handle delete and remove password options from SendScreen (#607)
This commit is contained in:
parent
6e91332a31
commit
32c1c2155e
4 changed files with 247 additions and 11 deletions
|
@ -27,6 +27,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
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.base.util.IntentHandler
|
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
|
||||||
|
@ -77,6 +79,9 @@ fun SendScreen(
|
||||||
|
|
||||||
SendDialogs(
|
SendDialogs(
|
||||||
dialogState = state.dialogState,
|
dialogState = state.dialogState,
|
||||||
|
onDismissRequest = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(SendAction.DismissDialog) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
val sendHandlers = remember(viewModel) { SendHandlers.create(viewModel) }
|
val sendHandlers = remember(viewModel) { SendHandlers.create(viewModel) }
|
||||||
|
@ -176,8 +181,17 @@ fun SendScreen(
|
||||||
@Composable
|
@Composable
|
||||||
private fun SendDialogs(
|
private fun SendDialogs(
|
||||||
dialogState: SendState.DialogState?,
|
dialogState: SendState.DialogState?,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (dialogState) {
|
when (dialogState) {
|
||||||
|
is SendState.DialogState.Error -> BitwardenBasicDialog(
|
||||||
|
visibilityState = BasicDialogState.Shown(
|
||||||
|
title = dialogState.title,
|
||||||
|
message = dialogState.message,
|
||||||
|
),
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
)
|
||||||
|
|
||||||
is SendState.DialogState.Loading -> BitwardenLoadingDialog(
|
is SendState.DialogState.Loading -> BitwardenLoadingDialog(
|
||||||
visibilityState = LoadingDialogState.Shown(dialogState.message),
|
visibilityState = LoadingDialogState.Shown(dialogState.message),
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||||
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
|
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
|
||||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.SendData
|
import com.x8bit.bitwarden.data.vault.repository.model.SendData
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
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.Text
|
||||||
|
@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ -69,13 +72,64 @@ class SendViewModel @Inject constructor(
|
||||||
SendAction.TextTypeClick -> handleTextTypeClick()
|
SendAction.TextTypeClick -> handleTextTypeClick()
|
||||||
is SendAction.DeleteSendClick -> handleDeleteSendClick(action)
|
is SendAction.DeleteSendClick -> handleDeleteSendClick(action)
|
||||||
is SendAction.RemovePasswordClick -> handleRemovePasswordClick(action)
|
is SendAction.RemovePasswordClick -> handleRemovePasswordClick(action)
|
||||||
|
SendAction.DismissDialog -> handleDismissDialog()
|
||||||
is SendAction.Internal -> handleInternalAction(action)
|
is SendAction.Internal -> handleInternalAction(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleInternalAction(action: SendAction.Internal): Unit = when (action) {
|
private fun handleInternalAction(action: SendAction.Internal): Unit = when (action) {
|
||||||
|
is SendAction.Internal.DeleteSendResultReceive -> handleDeleteSendResultReceive(action)
|
||||||
|
is SendAction.Internal.RemovePasswordSendResultReceive -> {
|
||||||
|
handleRemovePasswordSendResultReceive(action)
|
||||||
|
}
|
||||||
|
|
||||||
is SendAction.Internal.SendDataReceive -> handleSendDataReceive(action)
|
is SendAction.Internal.SendDataReceive -> handleSendDataReceive(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleDeleteSendResultReceive(action: SendAction.Internal.DeleteSendResultReceive) {
|
||||||
|
when (action.result) {
|
||||||
|
DeleteSendResult.Error -> {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialogState = SendState.DialogState.Error(
|
||||||
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
|
message = R.string.generic_error_message.asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DeleteSendResult.Success -> {
|
||||||
|
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||||
|
sendEvent(SendEvent.ShowToast(R.string.send_deleted.asText()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRemovePasswordSendResultReceive(
|
||||||
|
action: SendAction.Internal.RemovePasswordSendResultReceive,
|
||||||
|
) {
|
||||||
|
when (val result = action.result) {
|
||||||
|
is RemovePasswordSendResult.Error -> {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialogState = SendState.DialogState.Error(
|
||||||
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
|
message = result
|
||||||
|
.errorMessage
|
||||||
|
?.asText()
|
||||||
|
?: R.string.generic_error_message.asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is RemovePasswordSendResult.Success -> {
|
||||||
|
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||||
|
sendEvent(SendEvent.ShowToast(message = R.string.send_password_removed.asText()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleSendDataReceive(action: SendAction.Internal.SendDataReceive) {
|
private fun handleSendDataReceive(action: SendAction.Internal.SendDataReceive) {
|
||||||
when (val dataState = action.sendDataState) {
|
when (val dataState = action.sendDataState) {
|
||||||
is DataState.Error -> {
|
is DataState.Error -> {
|
||||||
|
@ -189,13 +243,31 @@ class SendViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDeleteSendClick(action: SendAction.DeleteSendClick) {
|
private fun handleDeleteSendClick(action: SendAction.DeleteSendClick) {
|
||||||
// TODO: Navigate to the text type send list screen (BIT-1388)
|
mutableStateFlow.update {
|
||||||
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
|
it.copy(dialogState = SendState.DialogState.Loading(R.string.deleting.asText()))
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = vaultRepo.deleteSend(action.sendItem.id)
|
||||||
|
sendAction(SendAction.Internal.DeleteSendResultReceive(result))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleRemovePasswordClick(action: SendAction.RemovePasswordClick) {
|
private fun handleRemovePasswordClick(action: SendAction.RemovePasswordClick) {
|
||||||
// TODO: Navigate to the text type send list screen (BIT-1388)
|
mutableStateFlow.update {
|
||||||
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
|
it.copy(
|
||||||
|
dialogState = SendState.DialogState.Loading(
|
||||||
|
message = R.string.removing_send_password.asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = vaultRepo.removePasswordSend(action.sendItem.id)
|
||||||
|
sendAction(SendAction.Internal.RemovePasswordSendResultReceive(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDismissDialog() {
|
||||||
|
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -283,6 +355,15 @@ data class SendState(
|
||||||
*/
|
*/
|
||||||
sealed class DialogState : Parcelable {
|
sealed class DialogState : Parcelable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a dismissible dialog with the given error [message].
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class Error(
|
||||||
|
val title: Text?,
|
||||||
|
val message: Text,
|
||||||
|
) : DialogState()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a loading dialog with the given [message].
|
* Represents a loading dialog with the given [message].
|
||||||
*/
|
*/
|
||||||
|
@ -372,10 +453,27 @@ sealed class SendAction {
|
||||||
val sendItem: SendState.ViewState.Content.SendItem,
|
val sendItem: SendState.ViewState.Content.SendItem,
|
||||||
) : SendAction()
|
) : SendAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss the currently displayed dialog.
|
||||||
|
*/
|
||||||
|
data object DismissDialog : SendAction()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Models actions that the [SendViewModel] itself will send.
|
* Models actions that the [SendViewModel] itself will send.
|
||||||
*/
|
*/
|
||||||
sealed class Internal : SendAction() {
|
sealed class Internal : SendAction() {
|
||||||
|
/**
|
||||||
|
* Indicates a result for deleting the send has been received.
|
||||||
|
*/
|
||||||
|
data class DeleteSendResultReceive(val result: DeleteSendResult) : Internal()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates a result for removing the password protection from a send has been received.
|
||||||
|
*/
|
||||||
|
data class RemovePasswordSendResultReceive(
|
||||||
|
val result: RemovePasswordSendResult,
|
||||||
|
) : Internal()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates that the send data has been received.
|
* Indicates that the send data has been received.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -589,6 +589,36 @@ class SendScreenTest : BaseComposeTest() {
|
||||||
composeTestRule.assertNoDialogExists()
|
composeTestRule.assertNoDialogExists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `error dialog should be displayed according to state`() {
|
||||||
|
val errorMessage = "Failure"
|
||||||
|
composeTestRule.onNode(isDialog()).assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist()
|
||||||
|
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialogState = SendState.DialogState.Error(
|
||||||
|
title = null,
|
||||||
|
message = errorMessage.asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(errorMessage)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Ok")
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
verify(exactly = 1) {
|
||||||
|
viewModel.trySendAction(SendAction.DismissDialog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `loading dialog should be displayed according to state`() {
|
fun `loading dialog should be displayed according to state`() {
|
||||||
val loadingMessage = "syncing"
|
val loadingMessage = "syncing"
|
||||||
|
|
|
@ -9,11 +9,14 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||||
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
|
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
|
||||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.SendData
|
import com.x8bit.bitwarden.data.vault.repository.model.SendData
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.concat
|
import com.x8bit.bitwarden.ui.platform.base.util.concat
|
||||||
import com.x8bit.bitwarden.ui.tools.feature.send.util.toViewState
|
import com.x8bit.bitwarden.ui.tools.feature.send.util.toViewState
|
||||||
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.just
|
import io.mockk.just
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
@ -108,22 +111,99 @@ class SendViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `DeleteSendClick should emit ShowToast`() = runTest {
|
fun `DeleteSendClick with deleteSend error should display error dialog`() = runTest {
|
||||||
val sendItem = mockk<SendState.ViewState.Content.SendItem>()
|
val sendId = "sendId1234"
|
||||||
|
val sendItem = mockk<SendState.ViewState.Content.SendItem> {
|
||||||
|
every { id } returns sendId
|
||||||
|
}
|
||||||
|
coEvery { vaultRepo.deleteSend(sendId) } returns DeleteSendResult.Error
|
||||||
|
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
viewModel.eventFlow.test {
|
viewModel.stateFlow.test {
|
||||||
|
assertEquals(DEFAULT_STATE, awaitItem())
|
||||||
viewModel.trySendAction(SendAction.DeleteSendClick(sendItem))
|
viewModel.trySendAction(SendAction.DeleteSendClick(sendItem))
|
||||||
assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem())
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
dialogState = SendState.DialogState.Loading(R.string.deleting.asText()),
|
||||||
|
),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
dialogState = SendState.DialogState.Error(
|
||||||
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
|
message = R.string.generic_error_message.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `RemovePasswordClick should emit ShowToast`() = runTest {
|
fun `DeleteSendClick with deleteSend success should emit ShowToast`() = runTest {
|
||||||
val sendItem = mockk<SendState.ViewState.Content.SendItem>()
|
val sendId = "sendId1234"
|
||||||
|
val sendItem = mockk<SendState.ViewState.Content.SendItem> {
|
||||||
|
every { id } returns sendId
|
||||||
|
}
|
||||||
|
coEvery { vaultRepo.deleteSend(sendId) } returns DeleteSendResult.Success
|
||||||
|
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
viewModel.trySendAction(SendAction.DeleteSendClick(sendItem))
|
||||||
|
assertEquals(SendEvent.ShowToast(R.string.send_deleted.asText()), awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `RemovePasswordClick with removePasswordSend error should display error dialog`() =
|
||||||
|
runTest {
|
||||||
|
val sendId = "sendId1234"
|
||||||
|
val sendItem = mockk<SendState.ViewState.Content.SendItem> {
|
||||||
|
every { id } returns sendId
|
||||||
|
}
|
||||||
|
coEvery {
|
||||||
|
vaultRepo.removePasswordSend(sendId)
|
||||||
|
} returns RemovePasswordSendResult.Error(errorMessage = null)
|
||||||
|
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertEquals(DEFAULT_STATE, awaitItem())
|
||||||
|
viewModel.trySendAction(SendAction.RemovePasswordClick(sendItem))
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
dialogState = SendState.DialogState.Loading(
|
||||||
|
message = R.string.removing_send_password.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
dialogState = SendState.DialogState.Error(
|
||||||
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
|
message = R.string.generic_error_message.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `RemovePasswordClick with removePasswordSend success should emit ShowToast`() = runTest {
|
||||||
|
val sendId = "sendId1234"
|
||||||
|
val sendItem = mockk<SendState.ViewState.Content.SendItem> {
|
||||||
|
every { id } returns sendId
|
||||||
|
}
|
||||||
|
coEvery {
|
||||||
|
vaultRepo.removePasswordSend(sendId)
|
||||||
|
} returns RemovePasswordSendResult.Success(mockk())
|
||||||
|
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
viewModel.eventFlow.test {
|
viewModel.eventFlow.test {
|
||||||
viewModel.trySendAction(SendAction.RemovePasswordClick(sendItem))
|
viewModel.trySendAction(SendAction.RemovePasswordClick(sendItem))
|
||||||
assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem())
|
assertEquals(SendEvent.ShowToast(R.string.send_password_removed.asText()), awaitItem())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -206,6 +286,20 @@ class SendViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DismissDialog should clear the dialogState`() = runTest {
|
||||||
|
val initialState = DEFAULT_STATE.copy(
|
||||||
|
dialogState = SendState.DialogState.Error(
|
||||||
|
title = null,
|
||||||
|
message = "Test".asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val viewModel = createViewModel(initialState)
|
||||||
|
|
||||||
|
viewModel.trySendAction(SendAction.DismissDialog)
|
||||||
|
assertEquals(initialState.copy(dialogState = null), viewModel.stateFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `VaultRepository SendData Error should update view state to Error`() {
|
fun `VaultRepository SendData Error should update view state to Error`() {
|
||||||
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
|
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
|
||||||
|
|
Loading…
Reference in a new issue