mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
Display attachments in the UI (#754)
This commit is contained in:
parent
be8608e53a
commit
89fda64baa
8 changed files with 853 additions and 26 deletions
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<AttachmentsState, AttachmentsEvent, AttachmentsAction>(
|
||||
// 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<AttachmentItem>,
|
||||
) : 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<CipherView?>,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
},
|
||||
)
|
|
@ -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<AttachmentsEvent>()
|
||||
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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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<CipherView?>>(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<IntentManager.FileData>()
|
||||
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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue