diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index ee736d3..5d634d6 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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, ) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt new file mode 100644 index 0000000..e411ad9 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt @@ -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, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySettings.kt new file mode 100644 index 0000000..df0dc4c --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySettings.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt index 8a96db5..b12cc74 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt @@ -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( diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/DisplaySettingsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/DisplaySettingsTest.kt new file mode 100644 index 0000000..8c9c81a --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/DisplaySettingsTest.kt @@ -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") + } + } +}