Add UI for saving a send (#526)

This commit is contained in:
David Perez 2024-01-07 20:30:22 -06:00 committed by Álison Fernandes
parent 978e72899b
commit 1cfd85d9f8
6 changed files with 457 additions and 4 deletions

View file

@ -19,11 +19,15 @@ 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.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.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandlers
/**
@ -49,6 +53,13 @@ fun AddSendScreen(
}
}
AddSendDialogs(
dialogState = state.dialogState,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(AddSendAction.DismissDialogClick) }
},
)
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
@ -96,3 +107,25 @@ fun AddSendScreen(
}
}
}
@Composable
private fun AddSendDialogs(
dialogState: AddSendState.DialogState?,
onDismissRequest: () -> Unit,
) {
when (dialogState) {
is AddSendState.DialogState.Error -> BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = dialogState.title,
message = dialogState.message,
),
onDismissRequest = onDismissRequest,
)
is AddSendState.DialogState.Loading -> BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(dialogState.message),
)
null -> Unit
}
}

View file

@ -3,12 +3,18 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
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.tools.feature.send.addsend.util.toSendView
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -21,6 +27,7 @@ private const val KEY_STATE = "state"
@HiltViewModel
class AddSendViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val vaultRepo: VaultRepository,
) : BaseViewModel<AddSendState, AddSendEvent, AddSendAction>(
initialState = savedStateHandle[KEY_STATE] ?: AddSendState(
viewState = AddSendState.ViewState.Content(
@ -37,6 +44,7 @@ class AddSendViewModel @Inject constructor(
isHideByDefaultChecked = false,
),
),
dialogState = null,
),
) {
@ -48,6 +56,7 @@ class AddSendViewModel @Inject constructor(
override fun handleAction(action: AddSendAction): Unit = when (action) {
is AddSendAction.CloseClick -> handleCloseClick()
AddSendAction.DismissDialogClick -> handleDismissDialogClick()
is AddSendAction.SaveClick -> handleSaveClick()
is AddSendAction.FileTypeClick -> handleFileTypeClick()
is AddSendAction.TextTypeClick -> handleTextTypeClick()
@ -60,6 +69,33 @@ class AddSendViewModel @Inject constructor(
is AddSendAction.HideByDefaultToggle -> handleHideByDefaultToggle(action)
is AddSendAction.DeactivateThisSendToggle -> handleDeactivateThisSendToggle(action)
is AddSendAction.HideMyEmailToggle -> handleHideMyEmailToggle(action)
is AddSendAction.Internal -> handleInternalAction(action)
}
private fun handleInternalAction(action: AddSendAction.Internal): Unit = when (action) {
is AddSendAction.Internal.CreateSendResultReceive -> handleCreateSendResultReceive(action)
}
private fun handleCreateSendResultReceive(
action: AddSendAction.Internal.CreateSendResultReceive,
) {
when (action.result) {
CreateSendResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = AddSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
)
}
}
CreateSendResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(AddSendEvent.NavigateBack)
}
}
}
private fun handlePasswordChange(action: AddSendAction.PasswordChange) {
@ -88,7 +124,38 @@ class AddSendViewModel @Inject constructor(
private fun handleCloseClick() = sendEvent(AddSendEvent.NavigateBack)
private fun handleSaveClick() = sendEvent(AddSendEvent.ShowToast("Save Not Implemented"))
private fun handleSaveClick() {
onContent { content ->
if (content.common.name.isBlank()) {
mutableStateFlow.update {
it.copy(
dialogState = AddSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required.asText(
R.string.name.asText(),
),
),
)
}
return@onContent
}
mutableStateFlow.update {
it.copy(
dialogState = AddSendState.DialogState.Loading(
message = R.string.saving.asText(),
),
)
}
viewModelScope.launch {
val result = vaultRepo.createSend(content.toSendView())
sendAction(AddSendAction.Internal.CreateSendResultReceive(result))
}
}
}
private fun handleDismissDialogClick() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleNameChange(action: AddSendAction.NameChange) {
updateCommonContent {
@ -188,6 +255,7 @@ class AddSendViewModel @Inject constructor(
*/
@Parcelize
data class AddSendState(
val dialogState: DialogState?,
val viewState: ViewState,
) : Parcelable {
@ -251,6 +319,29 @@ data class AddSendState(
}
}
}
/**
* Represents the current state of any dialogs on the screen.
*/
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].
*/
@Parcelize
data class Loading(
val message: Text,
) : DialogState()
}
}
/**
@ -278,6 +369,11 @@ sealed class AddSendAction {
*/
data object CloseClick : AddSendAction()
/**
* User clicked to dismiss the current dialog.
*/
data object DismissDialogClick : AddSendAction()
/**
* User clicked the save button.
*/
@ -337,4 +433,14 @@ sealed class AddSendAction {
* User toggled the "deactivate this send" toggle.
*/
data class DeactivateThisSendToggle(val isChecked: Boolean) : AddSendAction()
/**
* Models actions that the [AddSendViewModel] itself might send.
*/
sealed class Internal : AddSendAction() {
/**
* Indicates a result for creating a send has been received.
*/
data class CreateSendResultReceive(val result: CreateSendResult) : Internal()
}
}

View file

@ -0,0 +1,57 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addsend.util
import com.bitwarden.core.SendFileView
import com.bitwarden.core.SendTextView
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState
import java.time.Instant
/**
* Transforms [AddSendState] into [SendView].
*/
// TODO: The 'key' needs to be updated in order to get the save operation to work (BIT-480)
fun AddSendState.ViewState.Content.toSendView(): SendView =
SendView(
id = null,
accessId = null,
name = common.name,
notes = common.noteInput,
key = "",
password = common.passwordInput.takeUnless { it.isBlank() },
type = selectedType.toSendType(),
file = toSendFileView(),
text = toSendTextView(),
maxAccessCount = common.maxAccessCount?.toUInt(),
accessCount = 0U,
disabled = common.isDeactivateChecked,
hideEmail = common.isHideEmailChecked,
revisionDate = Instant.now(),
deletionDate = Instant.now(),
expirationDate = null,
)
private fun AddSendState.ViewState.Content.SendType.toSendType(): SendType =
when (this) {
AddSendState.ViewState.Content.SendType.File -> SendType.FILE
is AddSendState.ViewState.Content.SendType.Text -> SendType.TEXT
}
private fun AddSendState.ViewState.Content.toSendFileView(): SendFileView? =
(this.selectedType as? AddSendState.ViewState.Content.SendType.File)?.let {
// TODO: Add support for these properties in order to save a file (BIT-480)
SendFileView(
id = "",
fileName = "",
size = "",
sizeName = "",
)
}
private fun AddSendState.ViewState.Content.toSendTextView(): SendTextView? =
(this.selectedType as? AddSendState.ViewState.Content.SendType.Text)?.let {
SendTextView(
text = it.input,
hidden = it.isHideByDefaultChecked,
)
}

View file

@ -1,11 +1,14 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addsend
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
@ -521,6 +524,60 @@ class AddSendScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist()
}
@Test
fun `error dialog should be displayed according to state`() {
val errorTitle = "Fail Title"
val errorMessage = "Fail Message"
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = AddSendState.DialogState.Error(
title = errorTitle.asText(),
message = errorMessage.asText(),
),
)
}
composeTestRule
.onNodeWithText(errorMessage)
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
@Test
fun `error dialog Ok click should send DismissDialogClick`() {
mutableStateFlow.update {
it.copy(
dialogState = AddSendState.DialogState.Error(
title = "Fail Title".asText(),
message = "Fail Message".asText(),
),
)
}
composeTestRule
.onNodeWithText("Ok")
.performClick()
verify { viewModel.trySendAction(AddSendAction.DismissDialogClick) }
}
@Test
fun `loading dialog should be displayed according to state`() {
val loadingMessage = "syncing"
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText(loadingMessage).assertDoesNotExist()
mutableStateFlow.update {
it.copy(dialogState = AddSendState.DialogState.Loading(loadingMessage.asText()))
}
composeTestRule
.onNodeWithText(loadingMessage)
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
companion object {
private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
name = "",
@ -543,6 +600,7 @@ class AddSendScreenTest : BaseComposeTest() {
private val DEFAULT_STATE = AddSendState(
viewState = DEFAULT_VIEW_STATE,
dialogState = null,
)
}
}

View file

@ -2,14 +2,39 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.SendView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toSendView
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AddSendViewModelTest : BaseViewModelTest() {
private val vaultRepository: VaultRepository = mockk()
@BeforeEach
fun setup() {
mockkStatic(ADD_SEND_STATE_EXTENSIONS_PATH)
}
@AfterEach
fun tearDown() {
unmockkStatic(ADD_SEND_STATE_EXTENSIONS_PATH)
}
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
@ -33,12 +58,94 @@ class AddSendViewModelTest : BaseViewModelTest() {
}
@Test
fun `SaveClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
fun `SaveClick with createSend success should emit NavigateBack`() = runTest {
val viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(name = "input"),
)
val initialState = DEFAULT_STATE.copy(viewState = viewState)
val mockSendView = mockk<SendView>()
every { viewState.toSendView() } returns mockSendView
coEvery { vaultRepository.createSend(mockSendView) } returns CreateSendResult.Success
val viewModel = createViewModel(initialState)
viewModel.eventFlow.test {
viewModel.trySendAction(AddSendAction.SaveClick)
assertEquals(AddSendEvent.ShowToast("Save Not Implemented"), awaitItem())
assertEquals(AddSendEvent.NavigateBack, awaitItem())
}
assertEquals(initialState, viewModel.stateFlow.value)
coVerify(exactly = 1) {
vaultRepository.createSend(mockSendView)
}
}
@Test
fun `SaveClick with createSend failure should show error dialog`() = runTest {
val viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(name = "input"),
)
val initialState = DEFAULT_STATE.copy(viewState = viewState)
val mockSendView = mockk<SendView>()
every { viewState.toSendView() } returns mockSendView
coEvery { vaultRepository.createSend(mockSendView) } returns CreateSendResult.Error
val viewModel = createViewModel(initialState)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(AddSendAction.SaveClick)
assertEquals(
initialState.copy(
dialogState = AddSendState.DialogState.Loading(
message = R.string.saving.asText(),
),
),
awaitItem(),
)
assertEquals(
initialState.copy(
dialogState = AddSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
),
awaitItem(),
)
}
coVerify(exactly = 1) {
vaultRepository.createSend(mockSendView)
}
}
@Test
fun `SaveClick with blank name should show error dialog`() {
val viewModel = createViewModel(DEFAULT_STATE)
viewModel.trySendAction(AddSendAction.SaveClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = AddSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required.asText(
R.string.name.asText(),
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `DismissDialogClick should clear the dialog state`() {
val viewModel = createViewModel(
DEFAULT_STATE.copy(
dialogState = AddSendState.DialogState.Error(
title = "Fail Title".asText(),
message = "Fail Message".asText(),
),
),
)
viewModel.trySendAction(AddSendAction.DismissDialogClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
@ -171,9 +278,13 @@ class AddSendViewModelTest : BaseViewModelTest() {
state: AddSendState? = null,
): AddSendViewModel = AddSendViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", state) },
vaultRepo = vaultRepository,
)
companion object {
private const val ADD_SEND_STATE_EXTENSIONS_PATH: String =
"com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.AddSendStateExtensionsKt"
private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
name = "",
maxAccessCount = null,
@ -195,6 +306,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
private val DEFAULT_STATE = AddSendState(
viewState = DEFAULT_VIEW_STATE,
dialogState = null,
)
}
}

View file

@ -0,0 +1,87 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addsend.util
import com.bitwarden.core.SendFileView
import com.bitwarden.core.SendType
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.Instant
class AddSendStateExtensionsTest {
private val fixedInstant: Instant = Instant.parse("2023-10-27T12:00:00Z")
@AfterEach
fun tearDown() {
// Some individual tests call mockkStatic so we will make sure this is always undone.
unmockkStatic(Instant::class)
}
@Test
fun `toSendView should create an appropriate SendView with file type`() {
val sendView = createMockSendView(number = 1, type = SendType.FILE).copy(
id = null,
accessId = null,
key = "",
accessCount = 0U,
expirationDate = null,
text = null,
file = SendFileView(
id = "",
fileName = "",
size = "",
sizeName = "",
),
)
mockkStatic(Instant::class)
every { Instant.now() } returns fixedInstant
val result = DEFAULT_VIEW_STATE
.copy(selectedType = AddSendState.ViewState.Content.SendType.File)
.toSendView()
assertEquals(sendView, result)
}
@Test
fun `toSendView should create an appropriate SendView with text type`() {
val sendView = createMockSendView(number = 1, type = SendType.TEXT).copy(
id = null,
accessId = null,
key = "",
accessCount = 0U,
expirationDate = null,
file = null,
)
mockkStatic(Instant::class)
every { Instant.now() } returns fixedInstant
val result = DEFAULT_VIEW_STATE.toSendView()
assertEquals(sendView, result)
}
}
private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
name = "mockName-1",
maxAccessCount = 1,
passwordInput = "mockPassword-1",
noteInput = "mockNotes-1",
isHideEmailChecked = false,
isDeactivateChecked = false,
)
private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text(
input = "mockText-1",
isHideByDefaultChecked = false,
)
private val DEFAULT_VIEW_STATE = AddSendState.ViewState.Content(
common = DEFAULT_COMMON_STATE,
selectedType = DEFAULT_SELECTED_TYPE_STATE,
)