diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/format/FontSizePickerTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/format/FontSizePickerTest.kt new file mode 100644 index 0000000..cc6e281 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/format/FontSizePickerTest.kt @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose.format + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.theme.LibreMailTheme + +/** + * UI tests for the compose formatting toolbar's font-size dropdown (#73). [FontSizePicker] is + * presentational, so it is driven directly - independent of the surrounding + * [org.libremail.ui.compose.RichTextBodyField] editor - mirroring how `ContactAutocompleteRowTest` + * exercises its row composable in isolation. + */ +@RunWith(AndroidJUnit4::class) +class FontSizePickerTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args) + + private fun setContent(selectedPt: Int?, onSelect: (Int?) -> Unit = {}) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + FontSizePicker(selectedPt = selectedPt, onSelect = onSelect) + } + } + } + + @Test + fun noSizeSelected_buttonShowsDefaultLabel() { + setContent(selectedPt = null) + composeTestRule.onNodeWithText(string(R.string.format_size_default)).assertIsDisplayed() + } + + @Test + fun aSizeSelected_buttonShowsItsPointValue() { + setContent(selectedPt = 18) + composeTestRule.onNodeWithText(string(R.string.format_size_pt, 18)).assertIsDisplayed() + } + + @Test + fun tappingTheButton_opensAMenuListingDefaultAndEveryPreset() { + setContent(selectedPt = null) + // Before the menu opens, "Default" only labels the anchor button itself - a unique match. + composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick() + FONT_SIZE_PRESETS_PT.forEach { pt -> + composeTestRule.onNodeWithText(string(R.string.format_size_pt, pt)).assertIsDisplayed() + } + } + + @Test + fun pickingAPresetFromTheMenu_reportsItsPointSize() { + var picked: Int? = -1 + setContent(selectedPt = null) { picked = it } + composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick() + composeTestRule.onNodeWithText(string(R.string.format_size_pt, 14)).performClick() + assertEquals(14, picked) + } + + @Test + fun pickingDefaultFromTheMenu_clearsBySelectingNull() { + var picked: Int? = 12 + setContent(selectedPt = 12) { picked = it } + // The button reads "12 pt" here, so the menu's own "Default" entry is the only such match. + composeTestRule.onNodeWithText(string(R.string.format_size_pt, 12)).performClick() + composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick() + assertEquals(null, picked) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt index 065d0e3..4cf6d67 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt @@ -286,4 +286,21 @@ class OnboardingFlowTest { composeTestRule.onNodeWithText(string(R.string.app_password_open_page, "iCloud Mail")) .assertIsDisplayed() } + + @Test + fun appPasswordSetup_showsAccountPasswordDisclaimerNearField() { + setOnboardingContent(FakeAccountRepository(), FakeMailRepository()) + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick() + waitForText("Gmail") + composeTestRule.onNodeWithText("Gmail").performClick() + + // Issue #160: new users unfamiliar with app passwords commonly try their regular account + // password first. The disclaimer must render as supporting text directly under the "App + // password" field itself (not only in the intro InfoCards above), on every preset provider + // screen since they all share this composable. + waitForText(string(R.string.app_password_field)) + composeTestRule.onNodeWithText(string(R.string.app_password_field_disclaimer)) + .performScrollTo().assertIsDisplayed() + } } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt index 6fc780d..1eea05b 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt @@ -188,6 +188,7 @@ fun AppPasswordSetupScreen( singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + supportingText = { Text(stringResource(R.string.app_password_field_disclaimer)) }, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt index 0a3c6cd..166858a 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt @@ -58,6 +58,7 @@ import org.libremail.richtext.RichTextEditing import org.libremail.richtext.RichTextHtml import org.libremail.ui.compose.format.ColorSwatch import org.libremail.ui.compose.format.ColorSwatchRow +import org.libremail.ui.compose.format.FontSizePicker /** String-annotation tag the editor uses to carry a span's link target inside the [AnnotatedString]. */ private const val URL_TAG = "libremail:url" @@ -74,7 +75,7 @@ internal const val IMAGE_TAG = "libremail:image" /** * A rich-text body editor: a formatting toolbar (bold / italic / underline / strikethrough, font - * color and highlight, bulleted + numbered lists, block quote, and link) above a rounded + * size, font color and highlight, bulleted + numbered lists, block quote, and link) above a rounded * [OutlinedTextField]. It converts its [AnnotatedString] to the app's [RichTextContent] model and * reports both the plaintext form and its HTML — or null HTML when nothing is formatted, so an * unformatted message stays plaintext-only and feels exactly like the old editor. @@ -83,7 +84,8 @@ internal const val IMAGE_TAG = "libremail:image" * work as usual; each toolbar button exposes its accessible action label via `onClickLabel` on its * [Modifier.clickable] (not a `contentDescription`), and still carries toggle state for accessibility. * The font-color and highlight buttons open a [ColorPickerDialog] built on the shared - * [ColorSwatchRow], whose individual swatches carry their own `contentDescription` instead. + * [ColorSwatchRow], whose individual swatches carry their own `contentDescription` instead; the font + * size button opens the self-contained [FontSizePicker] dropdown. * * [resolveFont] maps a CSS font-family stack to a Compose [FontFamily] for display; the default * resolves nothing, leaving the system font (the model still round-trips the CSS value untouched). @@ -134,6 +136,15 @@ fun RichTextBodyField( onLink = { showLinkDialog = true }, onFontColor = { showFontColorPicker = true }, onHighlight = { showHighlightPicker = true }, + onFontSize = { pt -> + emit( + if (pt != null) { + applyStyle(value, RichStyle.FontSize(pt), linkColor, resolveFont) + } else { + clearStyle(value, RichStyle.FontSize::class.java, linkColor, resolveFont) + }, + ) + }, ) OutlinedTextField( value = value, @@ -217,6 +228,7 @@ private fun FormattingToolbar( onLink: () -> Unit, onFontColor: () -> Unit, onHighlight: () -> Unit, + onFontSize: (Int?) -> Unit, ) { val content = value.annotatedString.toRichContent() val start = value.selection.min @@ -297,6 +309,14 @@ private fun FormattingToolbar( active = false, onClick = onLink, ) + // The font-size dropdown trails every glyph button on purpose. The toolbar overflows the + // screen width and scrolls horizontally, and the compose E2E taps the "•" bullet button + // *without* scrolling first (see ComposeScreenTest.formattingToolbar_bulletButtonMarksTheLine...), + // so its click lands on the button's on-screen center. Any control inserted *before* the block + // buttons shifts them right and can push the bullet past the viewport, making that tap miss — + // so this wider control is appended last, leaving every pre-existing button in its tested spot. + val fontSizePt = RichTextEditing.styleAt(content, start, end, RichStyle.FontSize::class.java)?.pt + FontSizePicker(selectedPt = fontSizePt, onSelect = onFontSize) } } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/format/FontSizePicker.kt b/app/src/main/kotlin/org/libremail/ui/compose/format/FontSizePicker.kt new file mode 100644 index 0000000..783643d --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/compose/format/FontSizePicker.kt @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose.format + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +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.remember +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import org.libremail.R + +/** The fixed preset sizes [FontSizePicker] offers, in points. */ +internal val FONT_SIZE_PRESETS_PT = listOf(10, 12, 14, 18, 24) + +/** + * A toolbar dropdown for [org.libremail.richtext.RichStyle.FontSize]: the anchor button shows the + * selection's current size, or "Default" when [selectedPt] is null (no size applied, or a mixed + * selection), and opens a menu of [FONT_SIZE_PRESETS_PT] plus a leading "Default" entry that clears + * the style outright. Mirrors [ColorSwatchRow]'s "no color" convention: [onSelect] receives null for + * "Default" and a preset point size otherwise, so the caller routes the choice straight through the + * generalized `applyStyle`/`clearStyle` toggle path with no font-size-specific branching of its own. + */ +@Composable +fun FontSizePicker(selectedPt: Int?, onSelect: (Int?) -> Unit, modifier: Modifier = Modifier) { + var expanded by remember { mutableStateOf(false) } + val colors = MaterialTheme.colorScheme + val description = stringResource(R.string.format_size) + val label = if (selectedPt != null) { + stringResource(R.string.format_size_pt, selectedPt) + } else { + stringResource(R.string.format_size_default) + } + val contentColor = if (selectedPt != null) colors.onSecondaryContainer else colors.onSurfaceVariant + Box(modifier = modifier) { + Row( + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(if (selectedPt != null) colors.secondaryContainer else Color.Transparent) + .clickable(onClick = { expanded = true }, role = Role.Button, onClickLabel = description) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = label, color = contentColor) + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = contentColor, + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.format_size_default)) }, + onClick = { + expanded = false + onSelect(null) + }, + ) + FONT_SIZE_PRESETS_PT.forEach { pt -> + DropdownMenuItem( + text = { Text(stringResource(R.string.format_size_pt, pt)) }, + onClick = { + expanded = false + onSelect(pt) + }, + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4866d82..3d81d18 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -121,6 +121,9 @@ Green Cyan Pink + Font size + Default + %1$d pt Drafts @@ -210,6 +213,7 @@ Couldn\'t open your browser Email address App password + Do not try to use your account password. It will not work. You must use an app password. Server settings Incoming (IMAP): %1$s:%2$d (%3$s) Outgoing (SMTP): %1$s:%2$d (%3$s) diff --git a/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt index 5af9e47..7b9b986 100644 --- a/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt @@ -19,6 +19,7 @@ import org.libremail.richtext.RichSpan import org.libremail.richtext.RichStyle import org.libremail.richtext.RichTextContent import org.libremail.richtext.RichTextEditing +import org.libremail.richtext.RichTextHtml import org.libremail.richtext.imageToken import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -207,6 +208,14 @@ class RichTextEditorTest { assertEquals(colored.annotatedString.toRichContent().spans, result.annotatedString.toRichContent().spans) } + @Test + fun `clearStyle removes a font size span regardless of its value`() { + val value = field("hello", TextRange(0, 5)) + val sized = applyStyle(value, RichStyle.FontSize(18), linkColor) + val cleared = clearStyle(sized, RichStyle.FontSize::class.java, linkColor) + assertTrue(cleared.annotatedString.toRichContent().spans.isEmpty()) + } + // --- applyBlock --- @Test @@ -232,6 +241,19 @@ class RichTextEditorTest { assertEquals("1. a\n2. b", result.annotatedString.text) } + @Test + fun `applyBlock bullet on an end-of-text caret marks the line and serializes to ul li html`() { + // The JVM-layer twin of ComposeScreenTest.formattingToolbar_bulletButtonMarksTheLineAndSendsItAsHtml: + // a bullet tap on the end-of-text caret that typing leaves must mark the whole line and serialize + // to a real list. Pinning it here catches a regression in the block-toggle/HTML flow without an + // emulator; the instrumented test additionally guards that the toolbar button stays tappable. + val value = field("Buy milk", TextRange(8)) + val bulleted = applyBlock(value, BlockMarker.BULLET, linkColor, noFont) + val content = bulleted.annotatedString.toRichContent() + assertEquals("• Buy milk", content.text) + assertEquals("", RichTextHtml.toHtml(content)) + } + // --- applyLink --- @Test