Merge branch 'revert/pre-purple-theme' into 'main'

Revert the purple/dark theme, back to the pre-purple palette

See merge request paulhitt/flamenet-messenger-android!94
This commit was merged in pull request #121.
This commit is contained in:
Paul Hitt
2026-09-08 17:36:19 +00:00
26 changed files with 117 additions and 460 deletions
@@ -62,13 +62,10 @@ fun CallScreenBody(
Column(
modifier = Modifier
.fillMaxSize()
// ⚠️ The app's ground, not `colorScheme.background`. That token is
// a flat blue, and painting it full-screen here stamped a solid
// rectangle over the gradient every other screen shows — which
// read as "a blue box around the call area". The brush is used
// rather than transparency so this composable still stands on its
// own in the screenshot harness.
.background(groundBrush())
// ⚠️ Back to the theme's own background: the gradient this used to
// avoid painting over went with the purple theme (reverted
// 2026-09-08), so there is nothing left to clash with.
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = Kit.margin)
.testTag("call-screen"),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -88,7 +85,7 @@ fun CallScreenBody(
initials,
fontSize = 40.sp,
fontWeight = FontWeight.Bold,
color = Palette.OnDark,
color = MaterialTheme.colorScheme.primary,
)
}
}
@@ -69,7 +69,7 @@ fun ColumnScope.CallsScreen(
Icon(
KitIcon.Call,
contentDescription = "New call",
tint = Palette.OnDark,
tint = Palette.Accent,
modifier = Modifier
.size(28.dp)
.clickable { showNewCall.value = true }
@@ -105,12 +105,12 @@ fun ColumnScope.CallsScreen(
overflow = TextOverflow.Ellipsis,
)
Icon(KitIcon.Call, contentDescription = null,
tint = Palette.OnDark, modifier = Modifier.size(20.dp))
tint = Palette.Accent, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(Space.md))
Icon(
KitIcon.Video,
contentDescription = "Video call",
tint = Palette.OnDark,
tint = Palette.Accent,
modifier = Modifier
.size(22.dp)
.clickable {
@@ -226,7 +226,7 @@ fun ColumnScope.CallsScreen(
Icon(
KitIcon.Video,
contentDescription = "Video call $name",
tint = Palette.OnDark,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
@@ -243,7 +243,7 @@ fun ColumnScope.CallsScreen(
Icon(
KitIcon.Call,
contentDescription = "Call $name",
tint = Palette.OnDark,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
@@ -438,7 +438,7 @@ fun ColumnScope.GroupsSection(
androidx.compose.material3.Icon(
if (creating) KitIcon.Close else KitIcon.NewGroup,
contentDescription = "New group",
tint = Palette.OnDark,
tint = Palette.Accent,
modifier = Modifier
.size(22.dp)
.clickable { onCreatingChange(!creating) }
@@ -448,7 +448,6 @@ fun ColumnScope.GroupsSection(
if (creating) {
OutlinedTextField(
colors = fieldColors(),
value = name,
onValueChange = { name = it },
label = { Text("Group name") },
@@ -797,7 +796,6 @@ fun ColumnScope.AccountScreen(
// same duplication the contact rows had.
SettingsRow(label = "Display name") {}
OutlinedTextField(
colors = fieldColors(),
value = displayName,
onValueChange = { displayName = it },
placeholder = { Text("How others see you", fontSize = 14.sp) },
@@ -819,7 +817,6 @@ fun ColumnScope.AccountScreen(
description = "Changing it signs this device out — that is what changing it means.",
) {}
OutlinedTextField(
colors = fieldColors(),
value = password,
onValueChange = { password = it },
placeholder = { Text("At least 8 characters", fontSize = 14.sp) },
@@ -1361,16 +1358,13 @@ internal fun SectionBandPublic() = SectionBand()
@Composable
private fun SectionBand() {
// 🔴 **A gap, not a filled block.** On the light theme this was a 16pt band
// of #F3F3F3 — a barely-there grey that read as a seam between sections.
// Filled with `surfaceVariant` on the dark theme it became a light purple
// slab across the screen, which is the opposite of a separator: it drew
// more attention than the sections it was dividing.
//
// Letting the ground show through does the same job the grey band did,
// because the sections themselves are raised cards now — the gap between
// two panels IS the separator, and it needs no colour of its own.
Spacer(Modifier.fillMaxWidth().height(16.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 0.dp)
.height(16.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
)
}
/**
@@ -1449,9 +1443,7 @@ internal fun Avatar(
initials,
fontSize = (size.value * 0.33f).sp,
fontWeight = FontWeight.SemiBold,
// White on the avatar circle. The accent as ink on a dark
// circle is a light purple mark on a dark blue field.
color = Palette.OnDark,
color = MaterialTheme.colorScheme.primary,
)
}
}
@@ -1543,7 +1535,7 @@ private fun AlertGlyph(kind: String) {
Icon(
symbol,
contentDescription = null,
tint = Palette.OnDark,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
@@ -2853,7 +2845,7 @@ fun ColumnScope.StoryStrip(
Icon(
KitIcon.Add,
contentDescription = null,
tint = Palette.OnDark,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
@@ -2890,7 +2882,7 @@ fun ColumnScope.StoryStrip(
label.take(1).uppercase(),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Palette.OnDark,
color = MaterialTheme.colorScheme.primary,
)
}
Text(
@@ -2990,7 +2982,6 @@ private fun ColumnScope.GroupMembers(
if (renaming) {
OutlinedTextField(
colors = fieldColors(),
value = draftName,
onValueChange = { draftName = it },
label = { Text("Group name") },
@@ -3233,7 +3224,6 @@ fun ColumnScope.AddContactForm(
fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs))
OutlinedTextField(
colors = fieldColors(),
value = gatePin, onValueChange = { gatePin = it; gateWrong = false },
placeholder = { Text("Parent PIN", fontSize = 14.sp) },
singleLine = true,
@@ -3269,7 +3259,6 @@ fun ColumnScope.AddContactForm(
val card = remember(username) { ContactCard.decode(username) }
OutlinedTextField(
colors = fieldColors(),
value = username,
onValueChange = {
username = it
@@ -3898,7 +3887,6 @@ fun ColumnScope.MailboxesSection(
}
OutlinedTextField(
colors = fieldColors(),
value = host,
onValueChange = { host = it },
placeholder = { Text("relay.example.com", fontSize = 14.sp) },
@@ -3907,7 +3895,6 @@ fun ColumnScope.MailboxesSection(
)
Spacer(Modifier.height(Space.xs))
OutlinedTextField(
colors = fieldColors(),
value = passphrase,
onValueChange = { passphrase = it },
placeholder = { Text("Your passphrase", fontSize = 14.sp) },
@@ -4103,7 +4090,6 @@ private fun EditMessageSheet(
text = {
Column {
androidx.compose.material3.OutlinedTextField(
colors = fieldColors(),
value = text,
onValueChange = { text = it },
modifier = Modifier.fillMaxWidth().testTag("edit-field"),
@@ -4242,7 +4228,6 @@ internal fun ReportSheet(
Spacer(Modifier.height(Space.sm))
androidx.compose.material3.OutlinedTextField(
colors = fieldColors(),
value = note,
onValueChange = { note = it },
label = { Text("Anything you want to add (optional)") },
@@ -80,7 +80,7 @@ fun ColumnScope.ContactDetail(
Icon(
KitIcon.Back,
contentDescription = null,
tint = Palette.OnDark,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
@@ -111,8 +111,7 @@ fun ColumnScope.ContactDetail(
initials,
fontSize = 64.sp,
fontWeight = FontWeight.Bold,
// White on the avatar circle, like every other one.
color = Palette.OnDark,
color = MaterialTheme.colorScheme.primary,
)
}
}
@@ -62,13 +62,18 @@ fun ColumnScope.DisplaySection() {
settings.largerType = on; larger = on
}
// ⚠️ The Light / Dark / Match-the-phone choice is gone. The palette has one
// set of colours now and the app is dark whatever the phone says, so the
// control changed nothing a person could see. A setting that is still there
// and no longer works is worse than one that was removed — somebody sets
// it, nothing happens, and they are left wondering which of the two is
// broken. `DisplaySettings.appearance` is left in place so an existing
// stored value is not a crash; nothing reads it.
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
@@ -76,7 +81,8 @@ fun ColumnScope.DisplaySection() {
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.",
+ "something the app can do yet.\n\nAppearance takes effect next time the "
+ "app starts.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
@@ -145,7 +145,6 @@ private fun ColumnScope.FilesSetUp(
Spacer(Modifier.height(Space.md))
OutlinedTextField(
colors = fieldColors(),
value = passphrase, onValueChange = { passphrase = it },
label = { Text("Your Flamenet passphrase") },
visualTransformation = PasswordVisualTransformation(),
@@ -154,7 +153,6 @@ private fun ColumnScope.FilesSetUp(
)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
colors = fieldColors(),
value = again, onValueChange = { again = it },
label = { Text("Type it again") },
visualTransformation = PasswordVisualTransformation(),
@@ -189,7 +187,6 @@ private fun ColumnScope.FilesUnlock(
fontSize = 14.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.md))
OutlinedTextField(
colors = fieldColors(),
value = passphrase, onValueChange = { passphrase = it },
label = { Text("Passphrase") },
visualTransformation = PasswordVisualTransformation(),
@@ -455,7 +452,6 @@ private fun ColumnScope.AddFileSheet(
Text("Add a file", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
colors = fieldColors(),
value = name, onValueChange = { name = it },
label = { Text("Name") }, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-add-name"),
@@ -514,7 +510,6 @@ private fun ColumnScope.FolderNameSheet(
Text("New folder", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
colors = fieldColors(),
value = name, onValueChange = { name = it },
label = { Text("Name") }, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-folder-name"),
@@ -187,7 +187,6 @@ private fun PassphraseDialog(
Text(explainer, fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
colors = fieldColors(),
value = phrase,
onValueChange = { phrase = it },
placeholder = { Text("Passphrase", fontSize = 14.sp) },
@@ -198,7 +197,6 @@ private fun PassphraseDialog(
if (requireRepeat) {
Spacer(Modifier.height(Space.xs))
OutlinedTextField(
colors = fieldColors(),
value = repeat,
onValueChange = { repeat = it },
placeholder = { Text("Repeat passphrase", fontSize = 14.sp) },
@@ -1,10 +1,6 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@@ -56,31 +52,13 @@ fun KitPrimaryButton(
enabled: Boolean = true,
tag: String? = null,
) {
// ⚠️ The gradient goes on the MODIFIER and the Button is transparent.
// Compose's Button takes a single container colour, so a gradient painted
// inside the content would sit above the ripple and below nothing — it has
// to be the surface itself, clipped to the same shape or the corners show
// square.
//
// ⚠️ Disabled fades the whole gradient rather than swapping a flat colour
// in: a disabled button that changes shape as well as weight reads as a
// different control.
Button(
onClick = onClick,
enabled = enabled,
shape = RoundedCornerShape(KitButton.radius),
contentPadding = PaddingValues(horizontal = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
contentColor = Color.White,
disabledContentColor = Color.White,
),
modifier = modifier
.height(KitButton.height)
.clip(RoundedCornerShape(KitButton.radius))
.background(accentBrush(), RoundedCornerShape(KitButton.radius))
.alpha(if (enabled) 1f else 0.35f)
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
) {
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
@@ -3,8 +3,6 @@ package io.flamenet.messenger.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.ui.res.painterResource
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -27,7 +25,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import io.flamenet.messenger.R
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -112,27 +109,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
setContent {
MessengerTheme {
// The purple ground, painted once for the whole app. The
// Surface itself is transparent so the brush shows through;
// Scaffold below is transparent for the same reason, or its
// default container colour paints straight over it.
Surface(
color = androidx.compose.ui.graphics.Color.Transparent,
// 🔴 **`contentColor` MUST be set when `color` is
// Transparent.** Material derives it with
// `contentColorFor(color)`, which has no answer for
// Transparent and returns Unspecified — so `LocalContentColor`
// falls back to its own default, which is BLACK. Every
// `Text` that does not name a colour of its own then draws
// black on the dark ground.
//
// It looked like "some of the text is hard to read" rather
// than like a broken theme, because the many places that DO
// set a colour were fine and only the inheriting ones went
// dark.
contentColor = Palette.Ink,
modifier = Modifier.fillMaxSize().background(groundBrush()),
) {
Surface(modifier = Modifier.fillMaxSize()) {
// 🔴 **The gate is around Root, not inside it.** Drawn over
// the top, the message list still renders underneath for a
// frame — and a frame is all a screenshot or a shoulder
@@ -202,7 +179,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
@Composable
private fun Root(model: MessengerViewModel = viewModel()) {
val phase by model.phase.collectAsStateWithLifecycle()
Scaffold(containerColor = androidx.compose.ui.graphics.Color.Transparent) { padding ->
Scaffold { padding ->
val frame = Modifier.fillMaxSize().padding(padding).padding(20.dp)
when (val current = phase) {
// Scrollable, because a keyboard over a form needs somewhere to go.
@@ -365,22 +342,15 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// The product's mark. This was an accent-coloured tile with a bold "F"
// in it — a placeholder from before there was a logo, and the first
// thing anybody sees. iOS carries the same image; see ios!144.
//
// ⚠️ Clipped to a rounded rectangle, and that is not decoration. The
// artwork's background is painted rather than transparent — the space
// behind the globe is part of the picture — so an unclipped mark is a
// hard-edged dark rectangle on a pale ground. Rounded, it matches the
// launcher icon the person just tapped.
Image(
painter = painterResource(R.drawable.brand_mark),
contentDescription = null,
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(Radius.medium)),
)
.size(48.dp)
.clip(RoundedCornerShape(Radius.medium))
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text("F", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
Spacer(Modifier.height(Space.lg))
Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(Space.sm))
@@ -395,7 +365,6 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
Spacer(Modifier.height(Space.xl))
OutlinedTextField(
colors = fieldColors(),
value = relay,
onValueChange = model::setRelay,
label = { Text("Server") },
@@ -416,7 +385,6 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
}
OutlinedTextField(
colors = fieldColors(),
value = handle,
onValueChange = { handle = it },
label = { Text("Handle") },
@@ -431,7 +399,6 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
// would be collecting something nobody needs.
if (needsEmail) {
OutlinedTextField(
colors = fieldColors(),
value = email,
onValueChange = { email = it },
label = { Text("Email") },
@@ -447,7 +414,6 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
}
OutlinedTextField(
colors = fieldColors(),
value = passphrase,
onValueChange = { passphrase = it },
label = { Text("Passphrase") },
@@ -463,7 +429,6 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
// this device to refuse an under-13 sign-up, then forgotten. Nothing
// about age is sent to any server.
OutlinedTextField(
colors = fieldColors(),
value = birthYear,
onValueChange = { birthYear = it.filter(Char::isDigit).take(4) },
label = { Text("Year of birth") },
@@ -574,7 +539,6 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String)
) { Text("Send a code") }
OutlinedTextField(
colors = fieldColors(),
value = code,
onValueChange = { code = it },
label = { Text("Code from the email") },
@@ -582,7 +546,6 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String)
modifier = Modifier.fillMaxWidth().testTag("reset-code"),
)
OutlinedTextField(
colors = fieldColors(),
value = fresh,
onValueChange = { fresh = it },
label = { Text("New password") },
@@ -912,7 +875,7 @@ private fun BarAction(
Icon(
icon,
contentDescription = null,
tint = Palette.OnDark,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
@@ -76,7 +76,7 @@ fun ColumnScope.MessagesScreen(
androidx.compose.material3.Icon(
KitIcon.Scan,
contentDescription = "Scan an address",
tint = Palette.OnDark,
tint = Palette.Accent,
modifier = Modifier
.size(24.dp)
.clickable {
@@ -92,7 +92,7 @@ fun ColumnScope.MessagesScreen(
androidx.compose.material3.Icon(
if (addingContact) KitIcon.Close else KitIcon.Add,
contentDescription = "Add contact",
tint = Palette.OnDark,
tint = Palette.Accent,
modifier = Modifier
.size(26.dp)
.clickable { addingContact = !addingContact }
@@ -124,7 +124,6 @@ fun ColumnScope.MessagesScreen(
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
colors = fieldColors(),
value = query.value,
onValueChange = { query.value = it },
placeholder = { Text("Search conversations") },
@@ -168,7 +167,7 @@ fun ColumnScope.MessagesScreen(
androidx.compose.material3.Icon(
KitIcon.NewChat,
contentDescription = "New chat",
tint = Palette.OnDark,
tint = Palette.Accent,
modifier = Modifier
.size(22.dp)
.clickable { showNewChat.value = true }
@@ -148,7 +148,6 @@ fun ColumnScope.NetworkSection(
Spacer(Modifier.height(Space.md))
OutlinedTextField(
colors = fieldColors(),
value = draft,
onValueChange = { draft = it },
placeholder = { Text("obfs4 198.51.100.7:4444 …", fontSize = 13.sp) },
@@ -56,7 +56,6 @@ fun ParentalControlsSection() {
)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
colors = fieldColors(),
value = newPin, onValueChange = { newPin = it },
placeholder = { Text("Parent PIN (at least 4 digits)", fontSize = 14.sp) },
singleLine = true,
@@ -65,7 +64,6 @@ fun ParentalControlsSection() {
)
Spacer(Modifier.height(Space.xs))
OutlinedTextField(
colors = fieldColors(),
value = repeatPin, onValueChange = { repeatPin = it },
placeholder = { Text("Repeat PIN", fontSize = 14.sp) },
singleLine = true,
@@ -88,7 +86,6 @@ fun ParentalControlsSection() {
fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
colors = fieldColors(),
value = pinEntry, onValueChange = { pinEntry = it; wrong = false },
placeholder = { Text("Parent PIN", fontSize = 14.sp) },
singleLine = true,
@@ -95,7 +95,7 @@ fun ColumnScope.StoriesFeed(
Icon(
KitIcon.Add,
contentDescription = "New story",
tint = Palette.OnDark,
tint = Palette.Accent,
modifier = Modifier
.size(28.dp)
.clickable { choosing = true }
@@ -357,7 +357,6 @@ private fun StoryActions(
+ "saw this story can read it.", fontSize = 13.sp)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
colors = fieldColors(),
value = draft, onValueChange = { draft = it },
placeholder = { Text("Message") },
modifier = Modifier.fillMaxWidth().testTag("story-reply-field"),
@@ -454,7 +453,6 @@ fun StoryComposerDialog(
}
}
androidx.compose.material3.OutlinedTextField(
colors = fieldColors(),
value = caption.value,
onValueChange = { caption.value = it },
placeholder = { Text("Add a caption…") },
@@ -466,7 +464,6 @@ fun StoryComposerDialog(
// §6.13 — attach a live stream. The picked picture becomes its
// poster: what people see before deciding whether to connect.
androidx.compose.material3.OutlinedTextField(
colors = fieldColors(),
value = streamUrl.value,
onValueChange = { streamUrl.value = it },
label = { Text("Live stream link (optional)", fontSize = 12.sp) },
@@ -709,7 +706,6 @@ private fun TextStoryComposer(onDismiss: () -> Unit, onPost: (String) -> Unit) {
title = { Text("New story") },
text = {
OutlinedTextField(
colors = fieldColors(),
value = text,
onValueChange = { text = it },
placeholder = { Text("Say something") },
@@ -1,5 +1,6 @@
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
@@ -13,8 +14,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -52,164 +51,35 @@ object Palette {
// brand when this app was written and is not any more — the public pages
// carry none of it — so the names go with the colour rather than outliving
// it. A constant called `Ember` holding blue is how a palette drifts back.
val Accent = Color(0xFF7A3FF2) // buttons, bubbles, links
val AccentBright = Color(0xFFB99BFF) // links and small text on the dark ground
val AccentDeep = Color(0xFFB99BFF) // links and small text
val AccentSoft = Color(0x1A7A3FF2)
val Accent = Color(0xFF2298FF) // buttons, bubbles, links
val AccentBright = Color(0xFF4FACFE) // the kit's lighter primary swatch
val AccentSoft = Color(0x1A2298FF)
// The gradient.
//
// 🔴 **The LIGHT end is the one that has to carry the label.** A gradient
// button is only as readable as its lightest stop, and checking the mean
// or the dark end is how a pretty button ships unreadable. Measured with
// white text: #7A3FF2 is 5.50:1 and #5B27C7 is 8.20:1, so the label clears
// AA along the whole sweep rather than at one end of it.
val GradientTop = Color(0xFF7A3FF2)
val GradientBottom = Color(0xFF5B27C7)
// The page ground, as a gradient rather than a flat fill.
//
// Purple at the top, medium blue at the bottom — top down.
//
// ⚠️ The blue end is not decoration. The mark is a blue globe with an
// orange flame, and a purple-only app left the icon as the odd one out;
// shading into blue puts the icon's own colour on the page instead of
// beside it.
//
// 🔴 Every darkening step drags SecondaryText with it. Ink reads 12.3:1 at
// the top and 8.4:1 at the bottom, secondary text 8.1:1 and 5.5:1 — the
// bottom stop binds, and it is where most of the explanatory copy sits.
// White cards lift off the bottom at 2.4:1, which keeps a card a card.
val GroundTop = Color(0xFF3A1E9E) // purple
val GroundMid = Color(0xFF0B3080) // blue
val GroundBottom = Color(0xFF070A1A) // black
// Kept as the same two colours: there is one theme now.
val NightGroundTop = Color(0xFF3A1E9E)
val NightGroundBottom = Color(0xFF070A1A)
val Teal = Color(0xFF4BE3C0) // success and presence
val Pink = Color(0xFFFF819F) // the kit's alert accent
val Teal = Color(0xFF3BD5B2) // success and presence
val Pink = Color(0xFFED497C) // the kit's alert accent
// Neutrals, all from the kit.
val Ink = Color(0xFFEDEFF9)
// ⚠️ Still needed where a gradient cannot go (a single Color parameter, a
// system bar tint). This is the gradient's mid-tone, so the two never read
// as different surfaces.
val Mist = Color(0xFF191D68) // the page ground, flat
val SurfaceAlt = Color(0xFF1E2A70) // received bubbles, pills, bands
val IncomingBubble = Color(0xFF1E2A70)
val Ink = Color(0xFF090710)
val Mist = Color(0xFFF9F9F9) // the page ground
val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands
val IncomingBubble = Color(0xFFF3F3F3)
// ⚠️ A blue-tinted grey rather than the flat #F3F3F3: an initials avatar
// needs to read as a circle against the surface behind it, and two greys a
// shade apart do not.
// Icons and initials drawn ON a dark surface.
//
// 🔴 White, not the accent. The accent is a mid purple chosen to be
// readable as a FILL with white on top of it. Used as the ink for a glyph
// on a dark ground it is the wrong way round — a light purple mark on a
// dark blue field, the least legible thing on the screen, and it reads as
// a disabled control rather than a button.
//
// ⚠️ The accent still fills: primary buttons, the selected tab, badges.
val OnDark = Color(0xFFFFFFFF)
val AvatarFill = Color(0xFF243480)
// 🔴 **Darkened with the ground, not left behind it.** #505C69 read 5.0:1
// on the old bottom stop and only 3.5:1 on the darker one — below AA, and
// most of the explanatory copy on these screens is set in it. Measured now:
// 8.4:1 at the top of the gradient, 5.6:1 at the bottom.
// 🔴 **A mid-tone again, and that is the point of the flip.** On the light
// ground this had darkened at every step until it was #1B1822 — almost the
// same colour as Ink — so the hierarchy between primary and secondary text
// had collapsed while the contrast numbers still passed. On a dark ground
// it sits in the middle where it belongs: 7.3:1 on the ground, 5.7:1 on a
// card, 4.7:1 on a field.
val SecondaryText = Color(0xFFB4BAD4)
// One theme now, so one secondary. Kept as an alias so the dark
// colour scheme below still reads clearly.
val SecondaryTextDark = Color(0xFFB4BAD4)
val Divider = Color(0x2EB4BAD4)
val AvatarFill = Color(0xFFE8F1FB)
val SecondaryText = Color(0xFF505C69)
val Divider = Color(0x1A505C69)
// Semantic
val Success = Color(0xFF4BE3C0)
val Warning = Color(0xFFE9B44C)
val Danger = Color(0xFFFF819F)
val Success = Color(0xFF3BD5B2)
val Warning = Color(0xFFB8860B)
val Danger = Color(0xFFED497C)
// Dark-mode grounds — these ARE the site's, since the site is dark.
// 🔴 Darker than the ground, not lighter. The ground is vivid here, and a
// card lighter than a saturated violet has to be lighter still than that,
// which puts secondary text below AA on it. Recessed panels invert the
// problem: the gradient is the bright thing, content sits in dark wells.
// 🔴 One card colour against a THREE-stop ground, so it must lift off all
// three. #0E1745 looked right against the purple and the blue and measured
// 1.15:1 against the black end — a card that disappears at the bottom of a
// long screen. This lifts 1.41 / 1.33 / 1.23.
val InkSurface = Color(0xFF141C52) // --panel
val NightGround = Color(0xFF191D68) // --bg, the gradient's mid-tone
val InkSurface = Color(0xFF121A2E) // --panel
val NightGround = Color(0xFF0A0E1A) // --bg
}
/**
* The app's ground.
*
* ⚠️ One brush for the whole app rather than a fill per screen: a gradient
* applied per screen is a gradient that restarts at every navigation, which
* reads as the background flickering.
*/
@Composable
fun groundBrush(): Brush =
// One theme, so no branch. The Night* pair is kept pointing at the same two
// colours rather than deleted, because several screens name it directly and
// a rename across both apps is a separate change.
Brush.verticalGradient(
listOf(Palette.GroundTop, Palette.GroundMid, Palette.GroundBottom))
/** The primary action's fill. Matches `Theme.primaryGradient*` on iOS. */
fun accentBrush(): Brush =
Brush.verticalGradient(listOf(Palette.GradientTop, Palette.GradientBottom))
/**
* Text fields, and only text fields.
*
* 🔴 **White, on a dark app, deliberately.** An input is the one surface a
* person types into and reads back character by character, and a dark field on
* a dark ground gives it the least contrast on the screen at the moment it
* matters most. Typed text reads 18.3:1 on white; the placeholder 4.6:1,
* because a placeholder is still text. Matches `Theme.fieldFill` on iOS.
*
* ⚠️ **Not `SurfaceAlt`.** That fill is shared with incoming bubbles and the
* tab pill, which stay dark — a white message bubble on this ground would read
* as somebody else's app.
*
* ⚠️ Every colour a field draws has to be named, not just the container.
* Material derives label, placeholder, cursor and outline from the colour
* scheme, which is the DARK scheme here — leaving any of them unset puts a
* light-on-dark colour on a white box, and the placeholder simply disappears.
*/
object FieldPalette {
val fill = Color(0xFFFFFFFF)
val ink = Color(0xFF0E1230)
val hint = Color(0xFF6B7590)
}
@Composable
fun fieldColors() = OutlinedTextFieldDefaults.colors(
focusedContainerColor = FieldPalette.fill,
unfocusedContainerColor = FieldPalette.fill,
disabledContainerColor = FieldPalette.fill,
errorContainerColor = FieldPalette.fill,
focusedTextColor = FieldPalette.ink,
unfocusedTextColor = FieldPalette.ink,
disabledTextColor = FieldPalette.hint,
errorTextColor = FieldPalette.ink,
focusedLabelColor = FieldPalette.hint,
unfocusedLabelColor = FieldPalette.hint,
disabledLabelColor = FieldPalette.hint,
focusedPlaceholderColor = FieldPalette.hint,
unfocusedPlaceholderColor = FieldPalette.hint,
cursorColor = Palette.Accent,
focusedBorderColor = Palette.Accent,
unfocusedBorderColor = FieldPalette.hint,
)
/** Corner radii, matching `Theme.Radius` on iOS. */
object Radius {
val small = 10.dp
@@ -322,31 +192,53 @@ fun Modifier.flamenetCard(
@Composable
fun MessengerTheme(content: @Composable () -> Unit) {
// 🔴 **One theme, and it is dark.** There used to be a light scheme, a dark
// one, and a preference choosing between them. The palette has a single set
// of colours now, so the light branch would have drawn light-mode Material
// chrome under dark-mode artwork — and the preference would have been a
// control that changes nothing a person can see except the parts that
// break.
//
// ⚠️ `onBackground`/`onSurface` are `Ink`, not `Mist`. `Mist` is the
// ground now; using it as a text colour on that ground is dark-on-dark,
// which is exactly the kind of swap that survives a rename.
// ⚠️ 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 = darkColorScheme(
colorScheme = if (dark) {
darkColorScheme(
primary = Palette.AccentBright,
onPrimary = Color.White,
secondary = Palette.Accent,
background = Palette.NightGround,
surface = Palette.InkSurface,
surfaceVariant = Palette.InkSurface,
onBackground = Palette.Mist,
onSurface = Palette.Mist,
onSurfaceVariant = Palette.SecondaryText,
error = Palette.Danger,
outline = Palette.Divider,
)
} else {
lightColorScheme(
// ⚠️ The kit's #2298FF on white is about 3.3:1 — fine for a
// bold button label, not for small text. Anything small and
// blue must use a darker step; the two places that do are the
// section labels and the timestamps, and both are grey.
primary = Palette.Accent,
onPrimary = Color.White,
secondary = Palette.AccentDeep,
secondary = Palette.AccentBright,
background = Palette.Mist,
surface = Palette.InkSurface,
surface = Color.White,
surfaceVariant = Palette.SurfaceAlt,
onBackground = Palette.Ink,
onSurface = Palette.Ink,
onSurfaceVariant = Palette.SecondaryText,
error = Palette.Danger,
outline = Palette.Divider,
),
typography = MaterialTheme.typography,
)
},
shapes = FlamenetShapes,
typography = FlamenetTypography,
content = content,
)
}
@@ -129,7 +129,6 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) {
Text("Already have one?", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.xs))
OutlinedTextField(
colors = fieldColors(),
value = phrase,
onValueChange = { phrase = it },
placeholder = { Text("Your 12 or 24 word recovery phrase", fontSize = 13.sp) },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 122 KiB

@@ -1,146 +0,0 @@
package io.flamenet.messenger.app
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The two apps' palettes, checked rather than claimed.
*
* 🔴 **`Theme.swift` opens with "One palette, two apps… change a value here and
* change it there in the same commit" — and nothing enforced it.** Its own
* comment records what happened last time: the file claimed the two were
* matched while iOS was indigo and Android was ember. A promise in a comment is
* a promise nobody keeps, because nothing goes red when it is broken.
*
* This reads the iOS source and compares the hex values. It is coarse on
* purpose — it does not know what a colour means, only that both files say the
* same number for it.
*
* ⚠️ It needs the iOS checkout beside this one. When it is missing the test
* SKIPS rather than fails: a green suite on a machine that only has Android is
* the honest outcome, and a red one there would train people to ignore it.
*/
class PaletteMatchesIOSTest {
private val iosTheme: File? =
listOf(
File("../flamenetmessenger/Sources/Theme/Theme.swift"),
File("../../flamenetmessenger/Sources/Theme/Theme.swift"),
).firstOrNull { it.isFile }
/** `static let name = Color(hex: 0xRRGGBB)` and the light/dark pair form. */
private fun iosHex(source: String, name: String): String? {
val flat = Regex("""static let $name\s*=\s*Color\(hex:\s*0x([0-9A-Fa-f]{6})\)""")
.find(source)?.groupValues?.get(1)
if (flat != null) return flat.uppercase()
return Regex("""static let $name\s*=\s*Color\(light:\s*0x([0-9A-Fa-f]{6})""")
.find(source)?.groupValues?.get(1)?.uppercase()
}
private fun androidHex(color: androidx.compose.ui.graphics.Color): String =
"%02X%02X%02X".format(
(color.red * 255).toInt(), (color.green * 255).toInt(), (color.blue * 255).toInt())
@Test
fun `the accent and its gradient are the same on both platforms`() {
val ios = iosTheme?.readText() ?: return
for ((swift, android) in listOf(
"primary" to Palette.Accent,
"primaryGradientTop" to Palette.GradientTop,
"primaryGradientBottom" to Palette.GradientBottom,
"primaryDeep" to Palette.AccentDeep,
)) {
val expected = iosHex(ios, swift)
assertTrue(expected != null, "iOS Theme.swift has no `$swift` in a form this can read")
assertEquals(expected, androidHex(android),
"$swift differs: iOS says $expected, Android says ${androidHex(android)}")
}
}
/**
* 🔴 The half that decides whether the app is readable. A gradient button is
* only as readable as its LIGHTEST stop, and both platforms draw white on
* it.
*/
@Test
fun `white text clears AA on the lightest end of the button gradient`() {
val ratio = contrast(Palette.GradientTop, androidx.compose.ui.graphics.Color.White)
assertTrue(ratio >= 4.5,
"white on the gradient's light end is %.2f:1 — below AA for normal text".format(ratio))
}
/**
* 🔴 **One theme now, so one set of grounds — and every surface a person
* reads text on has to clear AA, not just the page.**
*
* The light theme is gone (2026-09-07). It went because the ground kept
* being asked to go darker and `secondaryText` had to darken with it at
* every step, until it was #1B1822 against ink at #090710: contrast passing
* while the hierarchy between primary and secondary text quietly vanished.
* A dark ground puts secondary back in the middle where it belongs.
*
* ⚠️ Cards and field fills are checked as well as the page. A gradient that
* passes and a bubble that does not is still an unreadable app, and the
* bubble is where the messages are.
*/
@Test
fun `text clears AA on every surface it is read on`() {
val surfaces = listOf(
"ground top (purple)" to Palette.GroundTop,
// ⚠️ The middle stop is a surface too. A three-stop gradient
// checked only at its ends is two thirds measured.
"ground middle (blue)" to Palette.GroundMid,
"ground bottom (black)" to Palette.GroundBottom,
"card" to Palette.InkSurface,
"field and incoming bubble" to Palette.SurfaceAlt,
"avatar" to Palette.AvatarFill,
)
for ((name, ground) in surfaces) {
val ink = contrast(ground, Palette.Ink)
assertTrue(ink >= 4.5, "ink on the $name is %.2f:1".format(ink))
// Secondary is the one that fails first — most of the explanatory
// copy on these screens is set in it.
val muted = contrast(ground, Palette.SecondaryText)
assertTrue(muted >= 4.5, "secondary text on the $name is %.2f:1".format(muted))
}
}
/**
* ⚠️ A card has to be visibly a card. Contrast rules say nothing about
* this, and a surface that matches its ground is a layout people cannot
* parse even though every text ratio passes.
*/
@Test
fun `cards and fields lift off what is behind them`() {
// 🔴 Against ALL THREE stops. One card colour has to survive the whole
// sweep, and the black end is the one it fails on.
for ((name, stop) in listOf("purple" to Palette.GroundTop,
"blue" to Palette.GroundMid,
"black" to Palette.GroundBottom)) {
val lift = contrast(Palette.InkSurface, stop)
assertTrue(lift >= 1.15,
"a card lifts off the $name end of the ground by only %.2f:1".format(lift))
}
val fieldLift = contrast(Palette.SurfaceAlt, Palette.InkSurface)
assertTrue(fieldLift >= 1.15, "a field lifts off its card by only %.2f:1".format(fieldLift))
}
private fun channel(c: Float): Double {
val v = c.toDouble()
return if (v <= 0.04045) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4)
}
private fun luminance(c: androidx.compose.ui.graphics.Color): Double =
0.2126 * channel(c.red) + 0.7152 * channel(c.green) + 0.0722 * channel(c.blue)
private fun contrast(
a: androidx.compose.ui.graphics.Color,
b: androidx.compose.ui.graphics.Color,
): Double {
val la = luminance(a)
val lb = luminance(b)
return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05)
}
}