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:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user