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
|
// 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
|
// from it — filing it as privacy would make the claim the section itself
|
||||||
// is careful not to make.
|
// is careful not to make.
|
||||||
|
SectionBand()
|
||||||
|
SettingsSection { DisplaySection() }
|
||||||
|
|
||||||
SectionBand()
|
SectionBand()
|
||||||
SettingsSection { NotificationSection() }
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(Kit.presenceDot)
|
.size(Kit.presenceDot)
|
||||||
@@ -2359,8 +2371,13 @@ private fun Bubble(
|
|||||||
authorName: String = "",
|
authorName: String = "",
|
||||||
picture: io.flamenet.e2e.Attachment.Ref? = null,
|
picture: io.flamenet.e2e.Attachment.Ref? = null,
|
||||||
) {
|
) {
|
||||||
val time = remember(line.at) {
|
val displayContext = LocalContext.current
|
||||||
SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(line.at))
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -2402,7 +2419,12 @@ private fun Bubble(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
||||||
modifier = Modifier.padding(horizontal = Kit.bubblePadding, vertical = 2.dp),
|
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
|
// 🔴 **An edit says so (§6.15).** Silently replacing the words is
|
||||||
// worse than the typo: the reader has no way to tell a correction
|
// worse than the typo: the reader has no way to tell a correction
|
||||||
// from their own misremembering, and no way to know the message
|
// from their own misremembering, and no way to know the message
|
||||||
@@ -2526,8 +2548,10 @@ private fun Bubble(
|
|||||||
if (line.text.isNotEmpty()) {
|
if (line.text.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
line.text,
|
line.text,
|
||||||
// 16sp regular, per the kit's Body Text 2.
|
// 16sp regular, per the kit's Body Text 2 — which is what
|
||||||
fontSize = 16.sp,
|
// `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
|
color = if (line.mine) MaterialTheme.colorScheme.onPrimary
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
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
|
package io.flamenet.messenger.app
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -191,7 +192,17 @@ fun Modifier.flamenetCard(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessengerTheme(content: @Composable () -> Unit) {
|
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(
|
MaterialTheme(
|
||||||
colorScheme = if (dark) {
|
colorScheme = if (dark) {
|
||||||
darkColorScheme(
|
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