Revert the purple/dark theme, back to the pre-purple palette #121
@@ -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(
|
||||
primary = Palette.Accent,
|
||||
onPrimary = Color.White,
|
||||
secondary = Palette.AccentDeep,
|
||||
background = Palette.Mist,
|
||||
surface = Palette.InkSurface,
|
||||
surfaceVariant = Palette.SurfaceAlt,
|
||||
onBackground = Palette.Ink,
|
||||
onSurface = Palette.Ink,
|
||||
onSurfaceVariant = Palette.SecondaryText,
|
||||
error = Palette.Danger,
|
||||
outline = Palette.Divider,
|
||||
),
|
||||
typography = MaterialTheme.typography,
|
||||
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.AccentBright,
|
||||
background = Palette.Mist,
|
||||
surface = Color.White,
|
||||
surfaceVariant = Palette.SurfaceAlt,
|
||||
onBackground = Palette.Ink,
|
||||
onSurface = Palette.Ink,
|
||||
onSurfaceVariant = Palette.SecondaryText,
|
||||
error = Palette.Danger,
|
||||
outline = Palette.Divider,
|
||||
)
|
||||
},
|
||||
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) },
|
||||
|
||||
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 74 KiB |
|
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)
|
||||
}
|
||||
}
|
||||