diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt index 2d04e60..e7204c7 100644 --- a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt +++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt @@ -73,6 +73,14 @@ data class AppSettings( * but exits (or whose process dies) before adding an account isn't forced to agree again. */ val licenseAccepted: Boolean = false, + /** + * The font family CSS / point size last used in a sent formatted message (issue #78), so the next + * brand-new composition ([org.libremail.ui.compose.ComposeViewModel]) can default to it. Either + * may be null on its own (e.g. only a size was ever recorded); both null means nothing has been + * remembered yet, so new messages stay plaintext exactly as before this preference existed. + */ + val lastFontCss: String? = null, + val lastFontSizePt: Int? = null, ) private object Keys { @@ -89,6 +97,8 @@ private object Keys { val RETENTION_MONTHS = intPreferencesKey("retention_months") val DEFAULT_ACCOUNT_ID = stringPreferencesKey("default_account_id") val LICENSE_ACCEPTED = booleanPreferencesKey("license_accepted") + val LAST_FONT_CSS = stringPreferencesKey("last_font_css") + val LAST_FONT_SIZE_PT = intPreferencesKey("last_font_size_pt") val BATTERY_PROMPT_HANDLED = booleanPreferencesKey("battery_prompt_handled") val CONTACTS_PROMPT_HANDLED = booleanPreferencesKey("contacts_prompt_handled") val CONTACTS_PERMISSION_REQUESTED = booleanPreferencesKey("contacts_permission_requested") @@ -116,6 +126,8 @@ internal fun Preferences.toAppSettings(): AppSettings = AppSettings( retentionMonths = this[Keys.RETENTION_MONTHS] ?: 0, defaultAccountId = this[Keys.DEFAULT_ACCOUNT_ID], licenseAccepted = this[Keys.LICENSE_ACCEPTED] ?: false, + lastFontCss = this[Keys.LAST_FONT_CSS], + lastFontSizePt = this[Keys.LAST_FONT_SIZE_PT], ) @Singleton @@ -221,6 +233,18 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con } } + /** + * Records the font family/size last used in a sent formatted message (#78), so the next brand-new + * composition can default to it. Either argument may be null on its own; passing null for one + * clears just that key rather than persisting a sentinel value. + */ + suspend fun setLastFont(fontCss: String?, fontSizePt: Int?) { + context.settingsDataStore.edit { + if (fontCss != null) it[Keys.LAST_FONT_CSS] = fontCss else it.remove(Keys.LAST_FONT_CSS) + if (fontSizePt != null) it[Keys.LAST_FONT_SIZE_PT] = fontSizePt else it.remove(Keys.LAST_FONT_SIZE_PT) + } + } + private suspend fun put(key: Preferences.Key, value: Boolean) { context.settingsDataStore.edit { it[key] = value } } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index bf13bfe..1760d10 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -28,6 +28,8 @@ import org.libremail.domain.model.OutgoingAttachment import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository +import org.libremail.richtext.RichBaseStyle +import org.libremail.richtext.RichStyle import org.libremail.richtext.RichTextContent import org.libremail.richtext.RichTextHtml import org.libremail.ui.navigation.Routes @@ -122,16 +124,20 @@ class ComposeViewModel @Inject constructor( // signature. Reply/forward drafts already carry theirs, so they take the draft branch above. viewModelScope.launch { val available = accountRepository.observeAccounts().first { it.isNotEmpty() } + val settings = settingsRepository.settings.first() // The persisted default (#163) only counts if it still names an account that exists. // Deleting the default account normally clears this via // SettingsRepository.clearDefaultAccountId, but a stale id could still reach here (e.g. // a Backup restore onto a device that never had the account) — validate rather than // trust it, so it just falls through to the incidental "first account alphabetically" // behavior instead of crashing or composing from a nonexistent account. - val defaultAccountId = settingsRepository.settings.first().defaultAccountId - val validDefaultAccountId = defaultAccountId?.takeIf { id -> available.any { it.id == id } } + val validDefaultAccountId = settings.defaultAccountId?.takeIf { id -> available.any { it.id == id } } val effectiveId = _state.value.fromAccountId ?: validDefaultAccountId ?: available.first().id applySignature(effectiveId) + // Seed the message-wide default font (#78) after the signature is applied, so the whole + // body — including the signature just appended — picks it up. New compositions only: + // replies/forwards resume as drafts and take the branch above, left untouched. + seedRememberedFont(settings.lastFontCss, settings.lastFontSizePt) } } } @@ -201,6 +207,45 @@ class ComposeViewModel @Inject constructor( /** Keeps an HTML body only when it actually carries formatting, so plaintext stays plaintext. */ private fun normalizedHtml(html: String): String? = if (html.isBlank() || !RichTextHtml.fromHtml(html).hasFormatting()) null else html + + /** + * Seeds a brand-new composition with the last-remembered font (#78) by wrapping the current body + * in a [RichBaseStyle], so the whole message — including any signature just applied — defaults to + * it. A no-op when nothing has been remembered yet, so a message that would otherwise stay + * plaintext-only isn't forced into an HTML body for no visible reason (an empty [RichBaseStyle] + * still flips [RichTextContent.hasFormatting]). + */ + private fun seedRememberedFont(fontCss: String?, fontSizePt: Int?) { + if (fontCss == null && fontSizePt == null) return + _state.update { s -> + val content = (s.bodyHtml?.let(RichTextHtml::fromHtml) ?: RichTextContent(text = s.body)) + .copy(baseStyle = RichBaseStyle(fontCss, fontSizePt)) + s.copy(bodyHtml = RichTextHtml.toHtml(content)) + } + } + + /** + * Remembers the font used in a just-sent formatted message (#78): the message-wide base style if + * one is set, else the last `FontFamily`/`FontSize` span in the body (the formatting nearest the + * end of the message). A no-op for a plaintext send ([bodyHtml] null) or a formatted one that never + * touched the font — an unrelated send must never clear a previously remembered preference. + */ + private suspend fun rememberFontPreference(bodyHtml: String?) { + val html = bodyHtml ?: return + val (fontCss, fontSizePt) = lastFontIn(RichTextHtml.fromHtml(html)) ?: return + settingsRepository.setLastFont(fontCss, fontSizePt) + } + + /** The base style if set, else the last matching inline font span; null when neither is present. */ + private fun lastFontIn(content: RichTextContent): Pair? { + content.baseStyle?.let { return it.fontCss to it.fontSizePt } + val css = content.spans.lastOrNull { it.style is RichStyle.FontFamily } + ?.let { (it.style as RichStyle.FontFamily).css } + val sizePt = content.spans.lastOrNull { it.style is RichStyle.FontSize } + ?.let { (it.style as RichStyle.FontSize).pt } + return if (css != null || sizePt != null) css to sizePt else null + } + fun addAttachments(items: List) = _state.update { it.copy(attachments = it.attachments + items) } fun removeAttachment(uri: String) = _state.update { @@ -331,6 +376,7 @@ class ComposeViewModel @Inject constructor( ), ).fold( onSuccess = { + rememberFontPreference(s.bodyHtml) draftId?.let { mailRepository.deleteDraft(it) } _state.update { it.copy(sending = false) } finish() diff --git a/app/src/test/kotlin/org/libremail/data/settings/AppSettingsTest.kt b/app/src/test/kotlin/org/libremail/data/settings/AppSettingsTest.kt index f003dc8..05af2f7 100644 --- a/app/src/test/kotlin/org/libremail/data/settings/AppSettingsTest.kt +++ b/app/src/test/kotlin/org/libremail/data/settings/AppSettingsTest.kt @@ -3,6 +3,7 @@ package org.libremail.data.settings import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.preferencesOf import androidx.datastore.preferences.core.stringPreferencesKey import org.junit.Test @@ -16,11 +17,17 @@ import kotlin.test.assertEquals * Also covers `licenseAccepted` (#172), the same kind of default/fallback mapping but for a plain * boolean: unset must read back as "not yet accepted" so a fresh install (and any pre-#172 install * that never wrote the key) is routed through `Routes.ONBOARDING_LICENSE` rather than skipping it. + * + * Also covers `lastFontCss`/`lastFontSizePt` (#78): the last-used compose font/size, read back by + * `ComposeViewModel` to seed brand-new compositions. Both are plain nullable round-trips — unset + * must read back as null (never a font is a valid, common state), not some sentinel. */ class AppSettingsTest { private val fetchPolicyKey = stringPreferencesKey("fetch_policy") private val licenseAcceptedKey = booleanPreferencesKey("license_accepted") + private val lastFontCssKey = stringPreferencesKey("last_font_css") + private val lastFontSizePtKey = intPreferencesKey("last_font_size_pt") @Test fun `in-memory default fetch policy is WIFI_ONLY`() { @@ -59,4 +66,33 @@ class AppSettingsTest { val prefs = preferencesOf(licenseAcceptedKey to true) assertEquals(true, prefs.toAppSettings().licenseAccepted) } + + @Test + fun `in-memory default has no remembered font`() { + assertEquals(null, AppSettings().lastFontCss) + assertEquals(null, AppSettings().lastFontSizePt) + } + + @Test + fun `a never-set font preference reads back as null`() { + val settings = emptyPreferences().toAppSettings() + assertEquals(null, settings.lastFontCss) + assertEquals(null, settings.lastFontSizePt) + } + + @Test + fun `a persisted font family and size round-trip independently`() { + val prefs = preferencesOf(lastFontCssKey to "Georgia, serif", lastFontSizePtKey to 14) + val settings = prefs.toAppSettings() + assertEquals("Georgia, serif", settings.lastFontCss) + assertEquals(14, settings.lastFontSizePt) + } + + @Test + fun `only the size can be set while the family stays null`() { + val prefs = preferencesOf(lastFontSizePtKey to 18) + val settings = prefs.toAppSettings() + assertEquals(null, settings.lastFontCss) + assertEquals(18, settings.lastFontSizePt) + } } diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt index a4dd10f..2ba791d 100644 --- a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt @@ -2,9 +2,11 @@ package org.libremail.ui.compose import androidx.lifecycle.SavedStateHandle +import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.Dispatchers @@ -33,6 +35,8 @@ import org.libremail.domain.model.ServerConfig import org.libremail.domain.model.Signature import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository +import org.libremail.richtext.RichBaseStyle +import org.libremail.richtext.RichTextHtml import org.libremail.ui.navigation.Routes import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -70,6 +74,9 @@ class ComposeViewModelTest { settings: Map = emptyMap(), mailRepository: MailRepository = mockk(relaxed = true), defaultAccountId: String? = null, + lastFontCss: String? = null, + lastFontSizePt: Int? = null, + settingsRepository: SettingsRepository = fakeSettingsRepository(defaultAccountId, lastFontCss, lastFontSizePt), ): ComposeViewModel { val accountRepository = mockk() every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts) @@ -80,8 +87,6 @@ class ComposeViewModelTest { } val signatureRepository = mockk() coEvery { signatureRepository.getDefault(any()) } answers { signatures[firstArg()] } - val settingsRepository = mockk() - every { settingsRepository.settings } returns flowOf(AppSettings(defaultAccountId = defaultAccountId)) return ComposeViewModel( savedStateHandle = savedState, mailRepository = mailRepository, @@ -93,6 +98,26 @@ class ComposeViewModelTest { ) } + /** + * A [SettingsRepository] mock carrying [AppSettings] built from the given fields, with + * [SettingsRepository.setLastFont] stubbed so callers that don't care about it (most tests) don't need to. + */ + private fun fakeSettingsRepository( + defaultAccountId: String? = null, + lastFontCss: String? = null, + lastFontSizePt: Int? = null, + ): SettingsRepository { + val repository = mockk() + val appSettings = AppSettings( + defaultAccountId = defaultAccountId, + lastFontCss = lastFontCss, + lastFontSizePt = lastFontSizePt, + ) + every { repository.settings } returns flowOf(appSettings) + coEvery { repository.setLastFont(any(), any()) } just Runs + return repository + } + @Test fun `appends the sending account default signature to a new message`() = runTest(testDispatcher) { val vm = viewModel(signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice"))) @@ -171,6 +196,38 @@ class ComposeViewModelTest { assertEquals("", vm.state.value.body) } + @Test + fun `seeds a brand-new composition with the remembered font`() = runTest(testDispatcher) { + // No signature configured, so the body is empty going in — the seeded base style still wraps it. + val vm = viewModel(lastFontCss = "Georgia, serif", lastFontSizePt = 14) + + val html = vm.state.value.bodyHtml + assertTrue(html != null, "expected a seeded HTML body, was null") + assertEquals(RichBaseStyle("Georgia, serif", 14), RichTextHtml.fromHtml(html).baseStyle) + } + + @Test + fun `seeds the remembered font onto a new message's signature`() = runTest(testDispatcher) { + val vm = viewModel( + signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")), + lastFontCss = "Georgia, serif", + lastFontSizePt = null, + ) + + val content = RichTextHtml.fromHtml(vm.state.value.bodyHtml!!) + assertEquals(RichBaseStyle("Georgia, serif", null), content.baseStyle) + // The signature text itself must still be present, just wrapped by the base style. + assertTrue(content.text.contains("Cheers, Alice"), "text=${content.text}") + } + + @Test + fun `stays plaintext-only when composing new with no remembered font`() = runTest(testDispatcher) { + val vm = viewModel(signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice"))) + + // No font preference stored: the plain signature must not gain an HTML body just from seeding. + assertNull(vm.state.value.bodyHtml) + } + @Test fun `does not append a signature when resuming a draft`() = runTest(testDispatcher) { val mailRepository = mockk(relaxed = true) @@ -196,6 +253,32 @@ class ComposeViewModelTest { assertEquals("

Draft body

", vm.state.value.bodyHtml) } + @Test + fun `does not seed the remembered font onto an existing draft`() = runTest(testDispatcher) { + val mailRepository = mockk(relaxed = true) + coEvery { mailRepository.getDraft("d1") } returns Draft( + id = "d1", + accountId = "imap:a", + to = "x@example.org", + cc = "", + subject = "Hi", + body = "Draft body", + updatedAt = 0L, + bodyHtml = "

Draft body

", + attachments = emptyList(), + ) + val vm = viewModel( + savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1")), + mailRepository = mailRepository, + lastFontCss = "Georgia, serif", + lastFontSizePt = 14, + ) + + assertEquals("Draft body", vm.state.value.body) + // Only brand-new compositions seed the remembered font; a resumed draft's HTML is untouched. + assertEquals("

Draft body

", vm.state.value.bodyHtml) + } + @Test fun `resuming a draft restores the bcc recipients`() = runTest(testDispatcher) { val mailRepository = mockk(relaxed = true) @@ -232,6 +315,67 @@ class ComposeViewModelTest { assertEquals("Hello", sent.captured.body) } + @Test + fun `persists the message-wide base style font on send`() = runTest(testDispatcher) { + val mailRepository = mockk(relaxed = true) + coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit) + val settingsRepository = fakeSettingsRepository() + val vm = viewModel(mailRepository = mailRepository, settingsRepository = settingsRepository) + + vm.onToChange("bob@example.org") + vm.onBodyChange("Hello", "

Hello

") + vm.send() + + coVerify { settingsRepository.setLastFont("Georgia, serif", 14) } + } + + @Test + fun `persists the last font span when the message has no base style`() = runTest(testDispatcher) { + val mailRepository = mockk(relaxed = true) + coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit) + val settingsRepository = fakeSettingsRepository() + val vm = viewModel(mailRepository = mailRepository, settingsRepository = settingsRepository) + + vm.onToChange("bob@example.org") + vm.onBodyChange( + "Hello there", + "

Hello " + + "there

", + ) + vm.send() + + // Each axis independently takes the last matching span: family from "Hello", size from "there". + coVerify { settingsRepository.setLastFont("Arial", 18) } + } + + @Test + fun `does not persist anything when the sent message has no font styling`() = runTest(testDispatcher) { + val mailRepository = mockk(relaxed = true) + coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit) + val settingsRepository = fakeSettingsRepository() + val vm = viewModel(mailRepository = mailRepository, settingsRepository = settingsRepository) + + vm.onToChange("bob@example.org") + vm.onBodyChange("Hello world", "

Hello world

") + vm.send() + + coVerify(exactly = 0) { settingsRepository.setLastFont(any(), any()) } + } + + @Test + fun `does not persist anything for a plaintext-only send`() = runTest(testDispatcher) { + val mailRepository = mockk(relaxed = true) + coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit) + val settingsRepository = fakeSettingsRepository() + val vm = viewModel(mailRepository = mailRepository, settingsRepository = settingsRepository) + + vm.onToChange("bob@example.org") + vm.onBodyChange("Hello world", null) + vm.send() + + coVerify(exactly = 0) { settingsRepository.setLastFont(any(), any()) } + } + @Test fun `prefills the form from mailto navigation arguments`() = runTest(testDispatcher) { val vm = viewModel(