feat(compose): remember last-used font and size #197

Merged
JMR-dev merged 2 commits from feat-78-remember-font-size into main 2026-07-03 04:55:24 +00:00
4 changed files with 254 additions and 4 deletions
@@ -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<Boolean>, value: Boolean) {
context.settingsDataStore.edit { it[key] = value }
}
@@ -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<String?, Int?>? {
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<OutgoingAttachment>) =
_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()
@@ -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)
}
}
@@ -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<String, AccountSettings> = 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<AccountRepository>()
every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts)
@@ -80,8 +87,6 @@ class ComposeViewModelTest {
}
val signatureRepository = mockk<SignatureRepository>()
coEvery { signatureRepository.getDefault(any()) } answers { signatures[firstArg<String>()] }
val settingsRepository = mockk<SettingsRepository>()
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<SettingsRepository>()
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<MailRepository>(relaxed = true)
@@ -196,6 +253,32 @@ class ComposeViewModelTest {
assertEquals("<p>Draft <b>body</b></p>", vm.state.value.bodyHtml)
}
@Test
fun `does not seed the remembered font onto an existing draft`() = runTest(testDispatcher) {
val mailRepository = mockk<MailRepository>(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 = "<p>Draft <b>body</b></p>",
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("<p>Draft <b>body</b></p>", vm.state.value.bodyHtml)
}
@Test
fun `resuming a draft restores the bcc recipients`() = runTest(testDispatcher) {
val mailRepository = mockk<MailRepository>(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<MailRepository>(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", "<div style=\"font-family:Georgia, serif;font-size:14pt\"><p>Hello</p></div>")
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<MailRepository>(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",
"<p><span style=\"font-family:Arial\">Hello</span> " +
"<span style=\"font-size:18pt\">there</span></p>",
)
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<MailRepository>(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", "<p>Hello <b>world</b></p>")
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<MailRepository>(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(