PM-34042: feat: Preview attachments from AttachmentsScreen (#6712)

This commit is contained in:
David Perez
2026-03-25 17:03:30 +00:00
committed by GitHub
parent db0cf19780
commit d58c82ced4
9 changed files with 100 additions and 16 deletions
@@ -257,6 +257,13 @@ fun NavGraphBuilder.vaultUnlockedGraph(
)
attachmentDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToPreviewAttachment = { cipherId, attachmentId, fileName ->
navController.navigateToPreviewAttachment(
cipherId = cipherId,
attachmentId = attachmentId,
fileName = fileName,
)
},
)
setupUnlockDestination(
onNavigateBack = {
@@ -70,6 +70,7 @@ fun AttachmentsContent(
AttachmentListEntry(
attachmentItem = it,
onDeleteClick = attachmentsHandlers.onDeleteClick,
onItemClick = attachmentsHandlers.onItemClick,
cardStyle = viewState.attachments.toListItemCardStyle(index = index),
modifier = Modifier
.fillMaxWidth()
@@ -141,6 +142,7 @@ fun AttachmentsContent(
private fun AttachmentListEntry(
attachmentItem: AttachmentsState.AttachmentItem,
onDeleteClick: (attachmentId: String) -> Unit,
onItemClick: (attachment: AttachmentsState.AttachmentItem) -> Unit,
cardStyle: CardStyle,
modifier: Modifier = Modifier,
) {
@@ -163,7 +165,11 @@ private fun AttachmentListEntry(
Row(
modifier = modifier
.defaultMinSize(minHeight = 60.dp)
.cardStyle(cardStyle = cardStyle, paddingStart = 16.dp)
.cardStyle(
cardStyle = cardStyle,
paddingStart = 16.dp,
onClick = { onItemClick(attachmentItem) },
)
.testTag("AttachmentRow"),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -34,10 +34,16 @@ fun SavedStateHandle.toAttachmentsArgs(): AttachmentsArgs {
*/
fun NavGraphBuilder.attachmentDestination(
onNavigateBack: () -> Unit,
onNavigateToPreviewAttachment: (
cipherId: String,
attachmentId: String,
fileName: String,
) -> Unit,
) {
composableWithSlideTransitions<AttachmentsRoute> {
AttachmentsScreen(
onNavigateBack = onNavigateBack,
onNavigateToPreview = onNavigateToPreviewAttachment,
)
}
}
@@ -41,6 +41,7 @@ fun AttachmentsScreen(
viewModel: AttachmentsViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
onNavigateBack: () -> Unit,
onNavigateToPreview: (cipherId: String, attachmentId: String, fileName: String) -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val attachmentsHandlers = remember(viewModel) { AttachmentsHandlers.create(viewModel) }
@@ -61,6 +62,9 @@ fun AttachmentsScreen(
}
is AttachmentsEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data)
is AttachmentsEvent.NavigateToPreview -> {
onNavigateToPreview(event.cipherId, event.attachmentId, event.fileName)
}
}
}
@@ -84,6 +84,7 @@ class AttachmentsViewModel @Inject constructor(
AttachmentsAction.ChooseFileClick -> handleChooseFileClick()
is AttachmentsAction.FileChoose -> handleFileChoose(action)
is AttachmentsAction.DeleteClick -> handleDeleteClick(action)
is AttachmentsAction.ItemClick -> handleItemClick(action)
is AttachmentsAction.Internal -> handleInternalAction(action)
}
}
@@ -192,6 +193,16 @@ class AttachmentsViewModel @Inject constructor(
}
}
private fun handleItemClick(action: AttachmentsAction.ItemClick) {
sendEvent(
AttachmentsEvent.NavigateToPreview(
cipherId = state.cipherId,
attachmentId = action.attachment.id,
fileName = action.attachment.title,
),
)
}
private fun handleInternalAction(action: AttachmentsAction.Internal) {
when (action) {
is AttachmentsAction.Internal.CipherReceive -> handleCipherReceive(action)
@@ -436,6 +447,15 @@ sealed class AttachmentsEvent {
*/
data object NavigateBack : AttachmentsEvent()
/**
* Navigates to preview the attachment.
*/
data class NavigateToPreview(
val cipherId: String,
val attachmentId: String,
val fileName: String,
) : AttachmentsEvent()
/**
* Show chooser sheet.
*/
@@ -495,12 +515,19 @@ sealed class AttachmentsAction {
) : AttachmentsAction()
/**
* User clicked delete an attachment.
* User clicked delete on an attachment.
*/
data class DeleteClick(
val attachmentId: String,
) : AttachmentsAction()
/**
* User clicked on an attachment.
*/
data class ItemClick(
val attachment: AttachmentsState.AttachmentItem,
) : AttachmentsAction()
/**
* Internal ViewModel actions.
*/
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.attachments.handlers
import com.bitwarden.ui.platform.model.FileData
import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsAction
import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsState
import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsViewModel
/**
@@ -13,6 +14,7 @@ data class AttachmentsHandlers(
val onChooseFileClick: () -> Unit,
val onFileChoose: (FileData) -> Unit,
val onDeleteClick: (attachmentId: String) -> Unit,
val onItemClick: (attachment: AttachmentsState.AttachmentItem) -> Unit,
val onDismissRequest: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
@@ -25,13 +27,10 @@ data class AttachmentsHandlers(
AttachmentsHandlers(
onBackClick = { viewModel.trySendAction(AttachmentsAction.BackClick) },
onSaveClick = { viewModel.trySendAction(AttachmentsAction.SaveClick) },
onChooseFileClick = {
viewModel.trySendAction(AttachmentsAction.ChooseFileClick)
},
onChooseFileClick = { viewModel.trySendAction(AttachmentsAction.ChooseFileClick) },
onFileChoose = { viewModel.trySendAction(AttachmentsAction.FileChoose(it)) },
onDeleteClick = {
viewModel.trySendAction(AttachmentsAction.DeleteClick(it))
},
onDeleteClick = { viewModel.trySendAction(AttachmentsAction.DeleteClick(it)) },
onItemClick = { viewModel.trySendAction(AttachmentsAction.ItemClick(it)) },
onDismissRequest = {
viewModel.trySendAction(AttachmentsAction.DismissDialogClick)
},
@@ -87,7 +87,7 @@ fun PreviewAttachmentScreen(
onClick = {
viewModel.trySendAction(PreviewAttachmentAction.DownloadClick)
},
modifier = Modifier.testTag("DownloadButton"),
modifier = Modifier.testTag("ToolbarDownloadButton"),
)
},
)
@@ -119,6 +119,7 @@ fun PreviewAttachmentScreen(
label = BitwardenString.download_file.asText(),
icon = rememberVectorPainter(id = BitwardenDrawable.ic_download),
onClick = { viewModel.trySendAction(PreviewAttachmentAction.DownloadClick) },
testTag = "ErrorStateDownloadButton",
),
modifier = Modifier.fillMaxSize(),
)
@@ -32,6 +32,7 @@ import org.junit.Test
class AttachmentsScreenTest : BitwardenComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToPreviewCalled = false
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<AttachmentsEvent>()
@@ -50,6 +51,7 @@ class AttachmentsScreenTest : BitwardenComposeTest() {
AttachmentsScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToPreview = { _, _, _ -> onNavigateToPreviewCalled = true },
)
}
}
@@ -60,6 +62,18 @@ class AttachmentsScreenTest : BitwardenComposeTest() {
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToPreview should call onNavigateToPreview`() {
mutableEventFlow.tryEmit(
AttachmentsEvent.NavigateToPreview(
cipherId = "cipherId",
attachmentId = "attachmentId",
fileName = "file.png",
),
)
assertTrue(onNavigateToPreviewCalled)
}
@Test
fun `on back click should send BackClick`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
@@ -34,6 +34,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class AttachmentsViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val authRepository: AuthRepository = mockk {
@@ -85,6 +86,24 @@ class AttachmentsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ItemClick should emit NavigateToPreview`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
AttachmentsAction.ItemClick(attachment = DEFAULT_ATTACHMENT_ITEM),
)
assertEquals(
AttachmentsEvent.NavigateToPreview(
cipherId = DEFAULT_STATE.cipherId,
attachmentId = DEFAULT_ATTACHMENT_ITEM.id,
fileName = DEFAULT_ATTACHMENT_ITEM.title,
),
awaitItem(),
)
}
}
@Test
fun `SaveClick should display error dialog when user is not Premium`() = runTest {
val cipherView = createMockCipherView(number = 1)
@@ -721,16 +740,17 @@ private val DEFAULT_STATE: AttachmentsState = AttachmentsState(
isPremiumUser = true,
)
private val DEFAULT_ATTACHMENT_ITEM: AttachmentsState.AttachmentItem =
AttachmentsState.AttachmentItem(
id = "mockId-1",
title = "mockFileName-1",
displaySize = "mockSizeName-1",
)
private val DEFAULT_CONTENT_WITH_ATTACHMENTS: AttachmentsState.ViewState.Content =
AttachmentsState.ViewState.Content(
originalCipher = createMockCipherView(number = 1),
attachments = listOf(
AttachmentsState.AttachmentItem(
id = "mockId-1",
title = "mockFileName-1",
displaySize = "mockSizeName-1",
),
),
attachments = listOf(DEFAULT_ATTACHMENT_ITEM),
newAttachment = null,
)