Merge branch 'main' into feat-contacts-permission-onboarding
This commit is contained in:
@@ -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<Attachment>, downloadedParts: Set<Int> = 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -131,6 +131,14 @@
|
||||
<string name="message_available_offline">Available offline</string>
|
||||
<string name="attachment_download_failed">Couldn\'t download %1$s</string>
|
||||
<string name="attachment_no_app">No app can open this file</string>
|
||||
<!-- Reader attachment accordion (#134). %1$d = attachments beyond the first, always one or more. -->
|
||||
<plurals name="attachments_see_more">
|
||||
<item quantity="one">See %1$d more attachment</item>
|
||||
<item quantity="other">See %1$d more attachments</item>
|
||||
</plurals>
|
||||
<string name="attachments_see_fewer">See fewer attachments</string>
|
||||
<string name="attachments_expand">Expand attachments</string>
|
||||
<string name="attachments_collapse">Collapse attachments</string>
|
||||
|
||||
<!-- Onboarding -->
|
||||
<string name="onboarding_welcome_title">Welcome to LibreMail</string>
|
||||
|
||||
Reference in New Issue
Block a user