diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsContent.kt new file mode 100644 index 0000000000..ad6ee6b4e1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsContent.kt @@ -0,0 +1,190 @@ +package com.x8bit.bitwarden.ui.vault.feature.attachments + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +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.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider +import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton +import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText +import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog +import com.x8bit.bitwarden.ui.vault.feature.attachments.handlers.AttachmentsHandlers + +/** + * The top level content UI state for the [AttachmentsScreen] when viewing a content. + */ +@Suppress("LongMethod") +@Composable +fun AttachmentsContent( + viewState: AttachmentsState.ViewState.Content, + attachmentsHandlers: AttachmentsHandlers, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + ) { + if (viewState.attachments.isEmpty()) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.no_attachments), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } else { + items(viewState.attachments) { + AttachmentListEntry( + attachmentItem = it, + onDeleteClick = attachmentsHandlers.onDeleteClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + item { + Spacer(modifier = Modifier.height(36.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.add_new_attachment), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.no_file_chosen), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenFilledTonalButton( + label = stringResource(id = R.string.choose_file), + onClick = attachmentsHandlers.onChooseFileClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = R.string.max_file_size), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + ) + } + + item { + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} + +@Composable +private fun AttachmentListEntry( + attachmentItem: AttachmentsState.AttachmentItem, + onDeleteClick: (attachmentId: String) -> Unit, + modifier: Modifier = Modifier, +) { + var shouldShowDeleteDialog by rememberSaveable { mutableStateOf(false) } + if (shouldShowDeleteDialog) { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.delete), + message = stringResource(id = R.string.do_you_really_want_to_delete), + confirmButtonText = stringResource(id = R.string.delete), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + shouldShowDeleteDialog = false + onDeleteClick(attachmentItem.id) + }, + onDismissClick = { shouldShowDeleteDialog = false }, + onDismissRequest = { shouldShowDeleteDialog = false }, + ) + } + + Row( + modifier = Modifier + .bottomDivider( + paddingStart = 16.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + .defaultMinSize(minHeight = 56.dp) + .padding(vertical = 8.dp) + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = attachmentItem.title, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = attachmentItem.displaySize, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = { shouldShowDeleteDialog = true }, + modifier = Modifier, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_trash), + contentDescription = stringResource(id = R.string.delete), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt index a999801b6d..8862239964 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt @@ -1,20 +1,14 @@ package com.x8bit.bitwarden.ui.vault.feature.attachments import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -24,27 +18,45 @@ 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.BitwardenErrorContent +import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent 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.NavigationIcon +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager import com.x8bit.bitwarden.ui.vault.feature.attachments.handlers.AttachmentsHandlers /** * Displays the attachments screen. */ +@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun AttachmentsScreen( viewModel: AttachmentsViewModel = hiltViewModel(), + intentManager: IntentManager = LocalIntentManager.current, onNavigateBack: () -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val attachmentsHandlers = remember(viewModel) { AttachmentsHandlers.create(viewModel) } + val fileChooserLauncher = intentManager.launchActivityForResult { activityResult -> + intentManager.getFileDataFromActivityResult(activityResult)?.let { + attachmentsHandlers.onFileChoose(it) + } + } val context = LocalContext.current EventsEffect(viewModel = viewModel) { event -> when (event) { AttachmentsEvent.NavigateBack -> onNavigateBack() + AttachmentsEvent.ShowChooserSheet -> { + fileChooserLauncher.launch( + intentManager.createFileChooserIntent(withCameraIntents = false), + ) + } + is AttachmentsEvent.ShowToast -> { Toast .makeText(context, event.message(context.resources), Toast.LENGTH_SHORT) @@ -53,7 +65,6 @@ fun AttachmentsScreen( } } - val attachmentHandlers = remember(viewModel) { AttachmentsHandlers.create(viewModel) } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier @@ -66,31 +77,35 @@ fun AttachmentsScreen( navigationIcon = NavigationIcon( navigationIcon = painterResource(id = R.drawable.ic_back), navigationIconContentDescription = stringResource(id = R.string.back), - onNavigationIconClick = attachmentHandlers.onBackClick, + onNavigationIconClick = attachmentsHandlers.onBackClick, ), actions = { BitwardenTextButton( label = stringResource(id = R.string.save), - onClick = attachmentHandlers.onSaveClick, + onClick = attachmentsHandlers.onSaveClick, ) }, ) }, ) { innerPadding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - item { - Text(text = "Not Yet Implemented") - } + val modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + when (val viewState = state.viewState) { + is AttachmentsState.ViewState.Content -> AttachmentsContent( + viewState = viewState, + attachmentsHandlers = attachmentsHandlers, + modifier = modifier, + ) - item { - Spacer(modifier = Modifier.navigationBarsPadding()) - } + is AttachmentsState.ViewState.Error -> BitwardenErrorContent( + message = viewState.message(), + modifier = modifier, + ) + + AttachmentsState.ViewState.Loading -> BitwardenLoadingContent( + modifier = modifier, + ) } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt index 062db3bc81..967aa15dcb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt @@ -2,10 +2,22 @@ package com.x8bit.bitwarden.ui.vault.feature.attachments import android.os.Parcelable import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.core.CipherView +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.model.DataState +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.platform.base.util.concat +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.vault.feature.attachments.util.toViewState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -16,18 +28,32 @@ private const val KEY_STATE = "state" */ @HiltViewModel class AttachmentsViewModel @Inject constructor( + private val vaultRepo: VaultRepository, savedStateHandle: SavedStateHandle, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. initialState = savedStateHandle[KEY_STATE] ?: AttachmentsState( cipherId = AttachmentsArgs(savedStateHandle).cipherId, + viewState = AttachmentsState.ViewState.Loading, ), ) { + init { + vaultRepo + .getVaultItemStateFlow(state.cipherId) + .map { AttachmentsAction.Internal.CipherReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + override fun handleAction(action: AttachmentsAction) { when (action) { AttachmentsAction.BackClick -> handleBackClick() AttachmentsAction.SaveClick -> handleSaveClick() + AttachmentsAction.ChooseFileClick -> handleChooseFileClick() + is AttachmentsAction.FileChoose -> handleFileChoose(action) + is AttachmentsAction.DeleteClick -> handleDeleteClick(action) + is AttachmentsAction.Internal -> handleInternalAction(action) } } @@ -39,6 +65,83 @@ class AttachmentsViewModel @Inject constructor( sendEvent(AttachmentsEvent.ShowToast("Not Yet Implemented".asText())) // TODO: Handle saving the attachments (BIT-522) } + + private fun handleChooseFileClick() { + sendEvent(AttachmentsEvent.ShowChooserSheet) + } + + private fun handleFileChoose(action: AttachmentsAction.FileChoose) { + sendEvent(AttachmentsEvent.ShowToast("Not Yet Implemented".asText())) + // TODO: Handle choosing a file the attachments (BIT-522) + } + + private fun handleDeleteClick(action: AttachmentsAction.DeleteClick) { + sendEvent(AttachmentsEvent.ShowToast("Not Yet Implemented".asText())) + // TODO: Handle choosing a file the attachments (BIT-522) + } + + private fun handleInternalAction(action: AttachmentsAction.Internal) { + when (action) { + is AttachmentsAction.Internal.CipherReceive -> handleCipherReceive(action) + } + } + + private fun handleCipherReceive(action: AttachmentsAction.Internal.CipherReceive) { + when (val dataState = action.cipherDataState) { + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + viewState = AttachmentsState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + is DataState.Loaded -> { + mutableStateFlow.update { + it.copy( + viewState = dataState + .data + ?.toViewState() + ?: AttachmentsState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + DataState.Loading -> { + mutableStateFlow.update { + it.copy(viewState = AttachmentsState.ViewState.Loading) + } + } + + is DataState.NoNetwork -> mutableStateFlow.update { + it.copy( + viewState = AttachmentsState.ViewState.Error( + message = R.string.internet_connection_required_title + .asText() + .concat("\n".asText()) + .concat(R.string.internet_connection_required_message.asText()), + ), + ) + } + + is DataState.Pending -> { + mutableStateFlow.update { + it.copy( + viewState = dataState + .data + ?.toViewState() + ?: AttachmentsState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + } + } } /** @@ -47,7 +150,44 @@ class AttachmentsViewModel @Inject constructor( @Parcelize data class AttachmentsState( val cipherId: String, -) : Parcelable + val viewState: ViewState, +) : Parcelable { + /** + * Represents the specific view states for the [AttachmentsScreen]. + */ + sealed class ViewState : Parcelable { + /** + * Represents an error state for the [AttachmentsScreen]. + */ + @Parcelize + data class Error(val message: Text) : ViewState() + + /** + * Loading state for the [AttachmentsScreen], signifying that the content is being + * processed. + */ + @Parcelize + data object Loading : ViewState() + + /** + * Represents a loaded content state for the [AttachmentsScreen]. + */ + @Parcelize + data class Content( + val attachments: List, + ) : ViewState() + } + + /** + * Represents an individual attachment that is already saved to the cipher. + */ + @Parcelize + data class AttachmentItem( + val id: String, + val title: String, + val displaySize: String, + ) : Parcelable +} /** * Represents a set of events related attachments. @@ -58,6 +198,11 @@ sealed class AttachmentsEvent { */ data object NavigateBack : AttachmentsEvent() + /** + * Show chooser sheet. + */ + data object ShowChooserSheet : AttachmentsEvent() + /** * Displays the given [message] as a toast. */ @@ -79,4 +224,35 @@ sealed class AttachmentsAction { * User clicked the save button. */ data object SaveClick : AttachmentsAction() + + /** + * User clicked to select a new attachment file. + */ + data object ChooseFileClick : AttachmentsAction() + + /** + * User has chosen the file attachment. + */ + data class FileChoose( + val fileData: IntentManager.FileData, + ) : AttachmentsAction() + + /** + * User clicked delete an attachment. + */ + data class DeleteClick( + val attachmentId: String, + ) : AttachmentsAction() + + /** + * Internal ViewModel actions. + */ + sealed class Internal : AttachmentsAction() { + /** + * The cipher data has been received. + */ + data class CipherReceive( + val cipherDataState: DataState, + ) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/handlers/AttachmentsHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/handlers/AttachmentsHandlers.kt index 452e119b30..892a53dea3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/handlers/AttachmentsHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/handlers/AttachmentsHandlers.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.vault.feature.attachments.handlers +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsAction import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsViewModel @@ -9,6 +10,9 @@ import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsViewModel data class AttachmentsHandlers( val onBackClick: () -> Unit, val onSaveClick: () -> Unit, + val onChooseFileClick: () -> Unit, + val onFileChoose: (IntentManager.FileData) -> Unit, + val onDeleteClick: (attachmentId: String) -> Unit, ) { companion object { /** @@ -19,6 +23,13 @@ data class AttachmentsHandlers( AttachmentsHandlers( onBackClick = { viewModel.trySendAction(AttachmentsAction.BackClick) }, onSaveClick = { viewModel.trySendAction(AttachmentsAction.SaveClick) }, + onChooseFileClick = { + viewModel.trySendAction(AttachmentsAction.ChooseFileClick) + }, + onFileChoose = { viewModel.trySendAction(AttachmentsAction.FileChoose(it)) }, + onDeleteClick = { + viewModel.trySendAction(AttachmentsAction.DeleteClick(it)) + }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensions.kt new file mode 100644 index 0000000000..57f5a2ee3c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensions.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.ui.vault.feature.attachments.util + +import com.bitwarden.core.CipherView +import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsState + +/** + * Converts the [CipherView] into a [AttachmentsState.ViewState.Content]. + */ +fun CipherView.toViewState(): AttachmentsState.ViewState.Content = + AttachmentsState.ViewState.Content( + attachments = this + .attachments + .orEmpty() + .mapNotNull { + val id = it.id ?: return@mapNotNull null + AttachmentsState.AttachmentItem( + id = id, + title = it.fileName.orEmpty(), + displaySize = it.sizeName.orEmpty(), + ) + }, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt index e4b7f2300a..efe3e31bb9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt @@ -1,13 +1,30 @@ package com.x8bit.bitwarden.ui.vault.feature.attachments +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +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.asText +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.util.isProgressBar +import com.x8bit.bitwarden.ui.util.onNodeWithContentDescriptionAfterScroll +import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs +import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert +import kotlinx.coroutines.flow.update +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -16,17 +33,19 @@ class AttachmentsScreenTest : BaseComposeTest() { private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val mutableEventFlow = bufferedMutableSharedFlow() - val viewModel: AttachmentsViewModel = mockk { + private val viewModel: AttachmentsViewModel = mockk { every { stateFlow } returns mutableStateFlow every { eventFlow } returns mutableEventFlow every { trySendAction(any()) } just runs } + private val intentManager: IntentManager = mockk(relaxed = true) @Before fun setup() { composeTestRule.setContent { AttachmentsScreen( viewModel = viewModel, + intentManager = intentManager, onNavigateBack = { onNavigateBackCalled = true }, ) } @@ -35,10 +54,163 @@ class AttachmentsScreenTest : BaseComposeTest() { @Test fun `NavigateBack should call onNavigateBack`() { mutableEventFlow.tryEmit(AttachmentsEvent.NavigateBack) - Assert.assertTrue(onNavigateBackCalled) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `on back click should send BackClick`() { + composeTestRule.onNodeWithContentDescription("Back").performClick() + verify(exactly = 1) { + viewModel.trySendAction(AttachmentsAction.BackClick) + } + } + + @Test + fun `on save click should send SaveClick`() { + composeTestRule.onNodeWithText("Save").performClick() + verify(exactly = 1) { + viewModel.trySendAction(AttachmentsAction.SaveClick) + } + } + + @Test + fun `on choose file click should send ChooseFileClick`() { + mutableStateFlow.update { + it.copy(viewState = AttachmentsState.ViewState.Content(emptyList())) + } + + composeTestRule.onNodeWithTextAfterScroll("Choose file").performClick() + verify(exactly = 1) { + viewModel.trySendAction(AttachmentsAction.ChooseFileClick) + } + } + + @Test + fun `progressbar should be displayed according to state`() { + mutableStateFlow.update { it.copy(viewState = AttachmentsState.ViewState.Loading) } + composeTestRule.onNode(isProgressBar).assertIsDisplayed() + + mutableStateFlow.update { + it.copy(viewState = AttachmentsState.ViewState.Error("Fail".asText())) + } + composeTestRule.onNode(isProgressBar).assertDoesNotExist() + + mutableStateFlow.update { + it.copy(viewState = AttachmentsState.ViewState.Content(emptyList())) + } + composeTestRule.onNode(isProgressBar).assertDoesNotExist() + } + + @Test + fun `error should be displayed according to state`() { + val errorMessage = "Fail" + mutableStateFlow.update { + it.copy(viewState = AttachmentsState.ViewState.Error(errorMessage.asText())) + } + composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed() + + mutableStateFlow.update { it.copy(viewState = AttachmentsState.ViewState.Loading) } + composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist() + + mutableStateFlow.update { + it.copy(viewState = AttachmentsState.ViewState.Content(emptyList())) + } + composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist() + } + + @Test + fun `content with no items should be displayed according to state`() { + mutableStateFlow.update { + it.copy(viewState = AttachmentsState.ViewState.Content(emptyList())) + } + composeTestRule + .onNodeWithTextAfterScroll("There are no attachments.") + .assertIsDisplayed() + } + + @Test + fun `content with items should be displayed according to state`() { + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS) + } + composeTestRule + .onNodeWithTextAfterScroll("cool_file.png") + .assertIsDisplayed() + } + + @Test + fun `on delete click should display confirmation dialog`() { + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS) + } + + composeTestRule.onNode(isDialog()).assertDoesNotExist() + + composeTestRule + .onNodeWithContentDescriptionAfterScroll("Delete") + .performClick() + + // Title + composeTestRule + .onAllNodesWithText("Delete") + .filterToOne(!hasClickAction()) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + // Description + composeTestRule + .onAllNodesWithText("Do you really want to delete? This cannot be undone.") + .filterToOne(!hasClickAction()) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + // Cancel Button + composeTestRule + .onNodeWithText("Cancel") + .assert(hasClickAction()) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + // Delete Button + composeTestRule + .onAllNodesWithText("Delete") + .filterToOne(hasClickAction()) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `on confirm delete click should send DeleteClick`() { + val cipherId = "cipherId-1234" + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS) + } + + composeTestRule + .onNodeWithContentDescriptionAfterScroll("Delete") + .performClick() + + composeTestRule + .onAllNodesWithText("Delete") + .filterToOne(hasClickAction()) + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(AttachmentsAction.DeleteClick(cipherId)) + } } } private val DEFAULT_STATE: AttachmentsState = AttachmentsState( cipherId = "cipherId-1234", + viewState = AttachmentsState.ViewState.Loading, ) + +private val DEFAULT_CONTENT_WITH_ATTACHMENTS: AttachmentsState.ViewState.Content = + AttachmentsState.ViewState.Content( + attachments = listOf( + AttachmentsState.AttachmentItem( + id = "cipherId-1234", + title = "cool_file.png", + displaySize = "10 MB", + ), + ), + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt index e7600cd0ea..8f93e8a7d6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt @@ -2,13 +2,43 @@ package com.x8bit.bitwarden.ui.vault.feature.attachments import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.bitwarden.core.CipherView +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView +import com.x8bit.bitwarden.data.vault.repository.VaultRepository 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.concat +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.vault.feature.attachments.util.toViewState +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.MutableStateFlow 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 AttachmentsViewModelTest : BaseViewModelTest() { + private val mutableVaultItemStateFlow = + MutableStateFlow>(DataState.Loading) + private val vaultRepository: VaultRepository = mockk { + every { getVaultItemStateFlow(any()) } returns mutableVaultItemStateFlow + } + + @BeforeEach + fun setup() { + mockkStatic(CipherView::toViewState) + } + + @AfterEach + fun tearDown() { + unmockkStatic(CipherView::toViewState) + } @Test fun `initial state should be correct when state is null`() = runTest { @@ -41,9 +71,146 @@ class AttachmentsViewModelTest : BaseViewModelTest() { } } + @Test + fun `ChooseFileClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AttachmentsAction.ChooseFileClick) + assertEquals(AttachmentsEvent.ShowChooserSheet, awaitItem()) + } + } + + @Test + fun `ChooseFile should emit ShowToast`() = runTest { + val fileData = mockk() + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AttachmentsAction.FileChoose(fileData)) + assertEquals(AttachmentsEvent.ShowToast("Not Yet Implemented".asText()), awaitItem()) + } + } + + @Test + fun `DeleteClick should emit ShowToast`() = runTest { + val attachmentId = "attachmentId-1234" + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AttachmentsAction.DeleteClick(attachmentId)) + assertEquals(AttachmentsEvent.ShowToast("Not Yet Implemented".asText()), awaitItem()) + } + } + + @Test + fun `vaultItemStateFlow Error should update state to Error`() = runTest { + mutableVaultItemStateFlow.tryEmit(value = DataState.Error(Throwable("Fail"))) + + val viewModel = createViewModel() + + assertEquals( + DEFAULT_STATE.copy( + viewState = AttachmentsState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `vaultItemStateFlow Loaded with data should update state to Content`() = runTest { + val cipherView = createMockCipherView(number = 1) + every { cipherView.toViewState() } returns DEFAULT_CONTENT_WITH_ATTACHMENTS + mutableVaultItemStateFlow.tryEmit(DataState.Loaded(cipherView)) + + val viewModel = createViewModel() + + assertEquals( + DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS), + viewModel.stateFlow.value, + ) + } + + @Test + fun `vaultItemStateFlow Loaded without data should update state to Content`() = runTest { + mutableVaultItemStateFlow.tryEmit(DataState.Loaded(null)) + + val viewModel = createViewModel() + + assertEquals( + DEFAULT_STATE.copy( + viewState = AttachmentsState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `vaultItemStateFlow Loading should update state to Loading`() = runTest { + mutableVaultItemStateFlow.tryEmit(value = DataState.Loading) + + val viewModel = createViewModel() + + assertEquals( + DEFAULT_STATE.copy(viewState = AttachmentsState.ViewState.Loading), + viewModel.stateFlow.value, + ) + } + + @Test + fun `vaultItemStateFlow NoNetwork should update state to Error`() = runTest { + mutableVaultItemStateFlow.tryEmit(value = DataState.NoNetwork(null)) + + val viewModel = createViewModel() + + assertEquals( + DEFAULT_STATE.copy( + viewState = AttachmentsState.ViewState.Error( + message = R.string.internet_connection_required_title + .asText() + .concat("\n".asText()) + .concat(R.string.internet_connection_required_message.asText()), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `vaultItemStateFlow Pending with data should update state to Content`() = runTest { + val cipherView = createMockCipherView(number = 1) + every { cipherView.toViewState() } returns DEFAULT_CONTENT_WITH_ATTACHMENTS + mutableVaultItemStateFlow.tryEmit(DataState.Pending(cipherView)) + + val viewModel = createViewModel() + + assertEquals( + DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS), + viewModel.stateFlow.value, + ) + } + + @Test + fun `vaultItemStateFlow Pending without data should update state to Content`() = runTest { + mutableVaultItemStateFlow.tryEmit(DataState.Pending(null)) + + val viewModel = createViewModel() + + assertEquals( + DEFAULT_STATE.copy( + viewState = AttachmentsState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + private fun createViewModel( initialState: AttachmentsState? = null, ): AttachmentsViewModel = AttachmentsViewModel( + vaultRepo = vaultRepository, savedStateHandle = SavedStateHandle().apply { set("state", initialState) set("cipher_id", initialState?.cipherId ?: "cipherId-1234") @@ -53,4 +220,16 @@ class AttachmentsViewModelTest : BaseViewModelTest() { private val DEFAULT_STATE: AttachmentsState = AttachmentsState( cipherId = "cipherId-1234", + viewState = AttachmentsState.ViewState.Loading, ) + +private val DEFAULT_CONTENT_WITH_ATTACHMENTS: AttachmentsState.ViewState.Content = + AttachmentsState.ViewState.Content( + attachments = listOf( + AttachmentsState.AttachmentItem( + id = "cipherId-1234", + title = "cool_file.png", + displaySize = "10 MB", + ), + ), + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensionsTest.kt new file mode 100644 index 0000000000..a76ac9eef3 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensionsTest.kt @@ -0,0 +1,62 @@ +package com.x8bit.bitwarden.ui.vault.feature.attachments.util + +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockAttachmentView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView +import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsState +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals + +class CipherViewExtensionsTest { + + @Test + fun `toViewState should return content with items when CipherView has attachments`() { + val cipherView = createMockCipherView(number = 1) + + val result = cipherView.toViewState() + + assertEquals( + AttachmentsState.ViewState.Content( + attachments = listOf( + AttachmentsState.AttachmentItem( + id = "mockId-1", + title = "mockFileName-1", + displaySize = "mockSizeName-1", + ), + ), + ), + result, + ) + } + + @Test + fun `toViewState should return content without item when CipherView has no attachments`() { + val cipherView = createMockCipherView(number = 1).copy( + attachments = null, + ) + + val result = cipherView.toViewState() + + assertEquals( + AttachmentsState.ViewState.Content(attachments = emptyList()), + result, + ) + } + + @Test + fun `toViewState should return content without items that have a null attachment ID`() { + val cipherView = createMockCipherView(number = 1).copy( + attachments = listOf( + createMockAttachmentView(number = 1).copy( + id = null, + ), + ), + ) + + val result = cipherView.toViewState() + + assertEquals( + AttachmentsState.ViewState.Content(attachments = emptyList()), + result, + ) + } +}