Merge branch 'feat/android-display-settings' into 'main'

Display settings, and one gap that is a missing feature rather than a toggle

See merge request paulhitt/flamenet-messenger-android!86
This commit was merged in pull request #113.
This commit is contained in:
Paul Hitt
2026-09-07 01:56:03 +00:00
5 changed files with 272 additions and 7 deletions
@@ -804,6 +804,9 @@ fun ColumnScope.AccountScreen(
// A bridge is about REACHING the relay on a hostile network, not hiding
// from it — filing it as privacy would make the claim the section itself
// is careful not to make.
SectionBand()
SettingsSection { DisplaySection() }
SectionBand()
SettingsSection { NotificationSection() }
@@ -1480,7 +1483,16 @@ internal fun Avatar(
)
}
}
if (online) {
// ⚠️ **Hides THEIR dot from this phone, not this phone from them.** The
// relay still tells contacts when this account is online whichever way
// the setting is set. iOS shipped the same control named "Show online
// status" on a Privacy screen, where it read as the reciprocal thing;
// it was renamed on 2026-09-06 and this one is named honestly from the
// start.
val showDot = LocalContext.current.let { ctx ->
remember { DisplaySettings(ctx).showOnlineStatus }
}
if (online && showDot) {
Box(
modifier = Modifier
.size(Kit.presenceDot)
@@ -2359,8 +2371,13 @@ private fun Bubble(
authorName: String = "",
picture: io.flamenet.e2e.Attachment.Ref? = null,
) {
val time = remember(line.at) {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(line.at))
val displayContext = LocalContext.current
val display = remember { DisplaySettings(displayContext) }
val showTimes = remember { display.showTimestamps }
val bodySize = remember { DisplaySettings.messageTextSize(display.largerType) }
val time = remember(line.at, showTimes) {
if (!showTimes) ""
else SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(line.at))
}
Row(
modifier = Modifier
@@ -2402,7 +2419,12 @@ private fun Bubble(
horizontalArrangement = Arrangement.spacedBy(Space.xs),
modifier = Modifier.padding(horizontal = Kit.bubblePadding, vertical = 2.dp),
) {
Text(time, fontSize = 10.sp, color = Palette.SecondaryText)
// ⚠️ Omitted entirely rather than drawn empty. An empty Text still
// takes its line height, so "timestamps off" would have left a
// blank gap above every message where the time used to be.
if (time.isNotEmpty()) {
Text(time, fontSize = 10.sp, color = Palette.SecondaryText)
}
// 🔴 **An edit says so (§6.15).** Silently replacing the words is
// worse than the typo: the reader has no way to tell a correction
// from their own misremembering, and no way to know the message
@@ -2526,8 +2548,10 @@ private fun Bubble(
if (line.text.isNotEmpty()) {
Text(
line.text,
// 16sp regular, per the kit's Body Text 2.
fontSize = 16.sp,
// 16sp regular, per the kit's Body Text 2 — which is what
// `messageTextSize` returns with larger type off, so this
// renders exactly as it always did until somebody asks.
fontSize = bodySize.sp,
color = if (line.mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
)
@@ -0,0 +1,89 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
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.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.sp
/**
* How this phone draws things.
*
* 🔴 **Named for what they do.** iOS shipped "Show online status" on a Privacy
* & Security screen, where every other messenger uses those words for the
* reciprocal thing; somebody switching it off to disappear would have been
* wrong, because it only hides other people's dots from their own phone. It was
* renamed on 2026-09-06 and this section is named that way from the start.
*/
@Composable
fun ColumnScope.DisplaySection() {
val context = LocalContext.current
val settings = remember { DisplaySettings(context) }
var online by remember { mutableStateOf(settings.showOnlineStatus) }
var times by remember { mutableStateOf(settings.showTimestamps) }
var larger by remember { mutableStateOf(settings.largerType) }
var appearance by remember { mutableStateOf(settings.appearance) }
Text("Display", style = MaterialTheme.typography.titleMedium)
@Composable
fun row(label: String, tag: String, value: Boolean, onChange: (Boolean) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, fontSize = 16.sp, modifier = Modifier.weight(1f))
Switch(checked = value, onCheckedChange = onChange,
modifier = Modifier.testTag(tag))
}
}
row("Show whether contacts are online", "display-online", online) { on ->
settings.showOnlineStatus = on; online = on
}
row("Show the time on messages", "display-timestamps", times) { on ->
settings.showTimestamps = on; times = on
}
row("Larger message text", "display-larger-type", larger) { on ->
settings.largerType = on; larger = on
}
Spacer(Modifier.height(Space.sm))
Text("Appearance", fontSize = 14.sp)
Row(modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs)) {
DisplaySettings.Appearance.entries.forEach { option ->
KitSecondaryButton(
text = if (option == appearance) "✓ ${option.label}" else option.label,
onClick = { settings.appearance = option; appearance = option },
modifier = Modifier.weight(1f).padding(end = Space.xs),
tag = "display-appearance-${option.stored}",
)
}
}
Spacer(Modifier.height(Space.xs))
// 🔴 Says the thing the label alone cannot: this hides them from you, not
// you from them.
Text(
"These change what this phone shows you. Your contacts are still told when "
+ "you are online whichever way this is set — hiding yourself is not "
+ "something the app can do yet.\n\nAppearance takes effect next time the "
+ "app starts.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
@@ -0,0 +1,84 @@
package io.flamenet.messenger.app
import android.content.Context
/**
* How this phone draws things. Preferences, not privacy.
*
* 🔴 **None of these change what anybody else sees.** "Show whether contacts
* are online" hides the dot on THIS screen; the relay still tells your contacts
* when you are online, exactly as before. iOS shipped the same three settings
* with names — "Show online status", "Show last seen" — that every other
* messenger uses for the reciprocal thing, on a screen called Privacy &
* Security, and somebody switching them off to disappear would have been wrong.
* They were renamed on 2026-09-06; these are named that way from the start.
*
* ⚠️ **There is no last-seen setting here, because Android does not show last
* seen at all.** iOS does. That is a missing feature rather than a missing
* toggle, and adding a switch for something the app never draws would be the
* exact decoration the parity scrub exists to catch.
*/
class DisplaySettings(context: Context) {
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/** Draw the presence dot on avatars. Hides theirs from you, not you from them. */
var showOnlineStatus: Boolean
get() = prefs.getBoolean(ONLINE_STATUS, true)
set(value) = prefs.edit().putBoolean(ONLINE_STATUS, value).apply()
/** The time above each message. */
var showTimestamps: Boolean
get() = prefs.getBoolean(TIMESTAMPS, true)
set(value) = prefs.edit().putBoolean(TIMESTAMPS, value).apply()
/** Bigger message text, for people who want it. */
var largerType: Boolean
get() = prefs.getBoolean(LARGER_TYPE, false)
set(value) = prefs.edit().putBoolean(LARGER_TYPE, value).apply()
/**
* Light, dark, or whatever the phone is set to.
*
* ⚠️ Stored as a string and clamped on read. An unrecognised value — from a
* future build, or a corrupted preference — reads as `system`, which is
* what somebody who never touched this setting has.
*/
var appearance: Appearance
get() = Appearance.from(prefs.getString(APPEARANCE, null))
set(value) = prefs.edit().putString(APPEARANCE, value.stored).apply()
enum class Appearance(val stored: String, val label: String) {
SYSTEM("system", "Match the phone"),
LIGHT("light", "Light"),
DARK("dark", "Dark");
companion object {
fun from(stored: String?) =
entries.firstOrNull { it.stored == stored } ?: SYSTEM
}
}
companion object {
/**
* The message font size, in sp.
*
* 🔴 Pure and lifted out so it can be asserted, for the same reason
* `NotificationSettings.messageText` is: a size computed inline in a
* `Text(...)` is a number nothing can test.
*/
/**
* ⚠️ The base is **16**, not a number of my choosing: the kit specifies
* 16sp for a message body ("Body Text 2"), and `ChatScreens` says so at
* the point it draws one. A setting that quietly restyled every message
* when it was switched OFF would be a worse bug than the one it fixes.
*/
fun messageTextSize(largerType: Boolean): Int = if (largerType) 20 else 16
private const val PREFS = "display-settings"
private const val ONLINE_STATUS = "showOnlineStatus"
private const val TIMESTAMPS = "showTimestamps"
private const val LARGER_TYPE = "largerType"
private const val APPEARANCE = "appearance"
}
}
@@ -1,6 +1,7 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.remember
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -191,7 +192,17 @@ fun Modifier.flamenetCard(
@Composable
fun MessengerTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
// ⚠️ Read here rather than passed in, so every entry point picks it up —
// the app has more than one, and a theme applied at only some of them is
// the kind of half-wiring that looks fine until somebody opens a call
// screen from a notification.
val context = androidx.compose.ui.platform.LocalContext.current
val preference = remember { DisplaySettings(context).appearance }
val dark = when (preference) {
DisplaySettings.Appearance.LIGHT -> false
DisplaySettings.Appearance.DARK -> true
DisplaySettings.Appearance.SYSTEM -> isSystemInDarkTheme()
}
MaterialTheme(
colorScheme = if (dark) {
darkColorScheme(
@@ -0,0 +1,57 @@
package io.flamenet.messenger.app
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The display preferences Android was missing.
*
* ⚠️ Only the pure decisions are here. Whether a switch is wired to the dot,
* the timestamp and the theme is not something a JVM test can see — that is
* what the instrumented suite and a person looking at the screen are for. What
* IS worth pinning is the arithmetic, because it has a default that must not
* move and a clamp that must not be skipped.
*/
class DisplaySettingsTest {
/**
* 🔴 **16sp with the setting off, because that is what the kit specifies
* for a message body.** A "larger type" switch that quietly restyled every
* message when it was turned OFF would be a worse bug than the absence it
* fixes, and the number is easy to get wrong because it lives in one file
* and is drawn in another.
*/
@Test
fun `message text is the kit size until larger type is asked for`() {
assertEquals(16, DisplaySettings.messageTextSize(largerType = false))
assertEquals(20, DisplaySettings.messageTextSize(largerType = true))
}
/**
* ⚠️ An unrecognised stored value reads as `system` — the state somebody
* who never touched this has. A value from a future build, or a corrupted
* preference, must not leave the app in no theme at all.
*/
@Test
fun `an unknown appearance falls back to matching the phone`() {
assertEquals(DisplaySettings.Appearance.SYSTEM, DisplaySettings.Appearance.from(null))
assertEquals(DisplaySettings.Appearance.SYSTEM, DisplaySettings.Appearance.from(""))
assertEquals(DisplaySettings.Appearance.SYSTEM,
DisplaySettings.Appearance.from("solarized-from-a-later-build"))
}
@Test
fun `the three appearances round trip through their stored form`() {
for (option in DisplaySettings.Appearance.entries) {
assertEquals(option, DisplaySettings.Appearance.from(option.stored))
}
}
/** Every option needs words a person can read on a button. */
@Test
fun `every appearance has a label`() {
for (option in DisplaySettings.Appearance.entries) {
assertEquals(true, option.label.isNotBlank(), "${option.name} has no label")
}
}
}