Display attachments in the UI (#754)

This commit is contained in:
David Perez
2024-01-24 15:08:02 -06:00
committed by Álison Fernandes
parent be8608e53a
commit 89fda64baa
8 changed files with 853 additions and 26 deletions

View File

@@ -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),
)
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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))
},
)
}
}

View File

@@ -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(),
)
},
)