From ae37823aec4e05377f52f664b667416432ae3be0 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 2 Jul 2026 09:56:56 -0500 Subject: [PATCH] feat(reader): collapse extra attachments into an accordion A message with several attachments used to render every AttachmentRow stacked vertically, pushing the message body arbitrarily far down. Now only the first attachment shows by default; when there is more than one, the extras collapse behind a "See x more attachments" control that expands and collapses with an animated, rotating chevron. A single attachment renders exactly as before (no accordion). The count uses a plurals resource (quantity one/other) so it reads "See 1 more attachment" / "See 2 more attachments" correctly. The toggle is one clickable Role.Button whose label and chevron contentDescription expose the expanded state to screen readers. Download/open behavior of each row is unchanged. Refs #134 Co-Authored-By: Claude Fable 5 --- .../libremail/ui/reader/ReaderScreenTest.kt | 66 ++++++++++++-- .../org/libremail/ui/reader/ReaderScreen.kt | 88 +++++++++++++++++-- app/src/main/res/values/strings.xml | 8 ++ 3 files changed, 147 insertions(+), 15 deletions(-) diff --git a/app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt index 1ccae3e..e7928b1 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt @@ -6,6 +6,8 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule 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 androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -20,7 +22,7 @@ import org.libremail.ui.FakeMailRepository import org.libremail.ui.navigation.Routes import org.libremail.ui.theme.LibreMailTheme -/** End-to-end test that the reader marks an already-cached attachment as downloaded. */ +/** Compose UI tests for the reader's attachment list: the downloaded indicator and the accordion. */ @RunWith(AndroidJUnit4::class) class ReaderScreenTest { @@ -29,6 +31,9 @@ class ReaderScreenTest { private fun string(resId: Int) = composeTestRule.activity.getString(resId) + private fun seeMore(extraCount: Int) = + composeTestRule.activity.resources.getQuantityString(R.plurals.attachments_see_more, extraCount, extraCount) + private val messageId = "imap:a:INBOX:1" private val message = Message( id = messageId, accountId = "imap:a", sender = "Sender", senderEmail = "s@example.org", @@ -36,29 +41,74 @@ class ReaderScreenTest { isRead = true, isStarred = false, ) - @Test - fun reader_showsDownloadedIndicator_forCachedAttachment() { + private fun attachment(partIndex: Int, filename: String) = + Attachment(messageId, partIndex, filename, "application/pdf", 1_000L) + + /** Renders [ReaderScreen] for the fixed [message] with the given [attachments] and awaits load. */ + private fun renderReader(attachments: List, downloadedParts: Set = emptySet()) { val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext val repo = FakeMailRepository( messages = listOf(message), - attachments = listOf(Attachment(messageId, 0, "report.pdf", "application/pdf", 1234L)), - downloadedParts = setOf(0), + attachments = attachments, + downloadedParts = downloadedParts, ) val viewModel = ReaderViewModel( SavedStateHandle(mapOf(Routes.READER_ARG_ID to messageId)), repo, SettingsRepository(context), ) - composeTestRule.setContent { LibreMailTheme(darkTheme = false, dynamicColor = false) { ReaderScreen(onBack = {}, onReply = { _, _, _ -> }, viewModel = viewModel) } } - composeTestRule.waitUntil(5_000) { - composeTestRule.onAllNodesWithText("report.pdf").fetchSemanticsNodes().isNotEmpty() + composeTestRule.onAllNodesWithText(attachments.first().filename).fetchSemanticsNodes().isNotEmpty() } + } + + @Test + fun reader_showsDownloadedIndicator_forCachedAttachment() { + renderReader(listOf(attachment(0, "report.pdf")), downloadedParts = setOf(0)) + + composeTestRule.onNodeWithText("report.pdf").assertIsDisplayed() composeTestRule.onNodeWithContentDescription(string(R.string.attachment_downloaded)).assertIsDisplayed() } + + @Test + fun reader_singleAttachment_showsNoAccordion() { + renderReader(listOf(attachment(0, "solo.pdf"))) + + composeTestRule.onNodeWithText("solo.pdf").assertIsDisplayed() + // A lone attachment has no "See more" control. + composeTestRule.onNodeWithContentDescription(string(R.string.attachments_expand)).assertDoesNotExist() + } + + @Test + fun reader_multipleAttachments_collapseExtrasUntilExpanded() { + renderReader(listOf(attachment(0, "one.pdf"), attachment(1, "two.pdf"), attachment(2, "three.pdf"))) + + // First row shown; the two extras are hidden behind the collapsed accordion. + composeTestRule.onNodeWithText("one.pdf").assertIsDisplayed() + composeTestRule.onNodeWithText(seeMore(2)).assertIsDisplayed() + composeTestRule.onNodeWithText("two.pdf").assertDoesNotExist() + composeTestRule.onNodeWithText("three.pdf").assertDoesNotExist() + + // Tapping the control reveals the remaining rows. + composeTestRule.onNodeWithText(seeMore(2)).performClick() + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText("two.pdf").fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText("two.pdf").assertIsDisplayed() + composeTestRule.onNodeWithText("three.pdf").assertIsDisplayed() + } + + @Test + fun reader_twoAttachments_useSingularPlural() { + renderReader(listOf(attachment(0, "a.pdf"), attachment(1, "b.pdf"))) + + // Exactly one extra: the singular plural form, e.g. "See 1 more attachment". + composeTestRule.onNodeWithText(seeMore(1)).assertIsDisplayed() + composeTestRule.onNodeWithText("b.pdf").assertDoesNotExist() + } } diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt index 7ad51ac..4b3895b 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt @@ -4,6 +4,8 @@ package org.libremail.ui.reader import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -25,6 +27,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Star import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -42,12 +45,18 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider @@ -213,18 +222,83 @@ private fun Attachments( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(8.dp)) - attachments.forEach { attachment -> - AttachmentRow( - attachment = attachment, - downloading = attachment.partIndex in downloading, - downloaded = attachment.partIndex in downloaded, - onClick = { onDownload(attachment) }, + // The first attachment always shows. Any extras collapse behind an accordion so a message + // with many attachments can't push its body off-screen (#134). + val first = attachments.first() + AttachmentRow( + attachment = first, + downloading = first.partIndex in downloading, + downloaded = first.partIndex in downloaded, + onClick = { onDownload(first) }, + ) + Spacer(Modifier.height(8.dp)) + val extras = attachments.drop(1) + if (extras.isNotEmpty()) { + var expanded by rememberSaveable { mutableStateOf(false) } + AttachmentsToggle( + extraCount = extras.size, + expanded = expanded, + onToggle = { expanded = !expanded }, ) - Spacer(Modifier.height(8.dp)) + AnimatedVisibility(visible = expanded) { + Column { + extras.forEach { attachment -> + Spacer(Modifier.height(8.dp)) + AttachmentRow( + attachment = attachment, + downloading = attachment.partIndex in downloading, + downloaded = attachment.partIndex in downloaded, + onClick = { onDownload(attachment) }, + ) + } + } + } } } } +/** + * Collapsed-by-default control that reveals the 2nd..Nth attachments. It is a single clickable + * [Role.Button] whose label ("See x more attachments" / "See fewer attachments") and rotating + * chevron expose the expanded state to screen readers. + */ +@Composable +private fun AttachmentsToggle(extraCount: Int, expanded: Boolean, onToggle: () -> Unit) { + val label = if (expanded) { + stringResource(R.string.attachments_see_fewer) + } else { + pluralStringResource(R.plurals.attachments_see_more, extraCount, extraCount) + } + val chevronDescription = stringResource( + if (expanded) R.string.attachments_collapse else R.string.attachments_expand, + ) + val rotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "attachmentsChevronRotation", + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .clickable(role = Role.Button, onClick = onToggle) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = chevronDescription, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.rotate(rotation), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } +} + @Composable private fun AttachmentRow(attachment: Attachment, downloading: Boolean, downloaded: Boolean, onClick: () -> Unit) { Surface( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8521a5d..687db18 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -131,6 +131,14 @@ Available offline Couldn\'t download %1$s No app can open this file + + + See %1$d more attachment + See %1$d more attachments + + See fewer attachments + Expand attachments + Collapse attachments Welcome to LibreMail