Take the design kit's layout, keep Flamenet orange

Measured out of `Messenger App V2.sketch` by reading the file's JSON
rather than eyeballing a render, and written down in DESIGN.md so both
apps cite one source instead of drifting.

The layout is theirs; the colour is ours. The kit's primary is blue and
adopting it would mean the app no longer looks like the product it
belongs to, so everywhere the kit says primary, read Flamenet orange.
Only #F3F3F3 is taken directly, as the received-bubble and pill fill.
The typeface is not adopted either -- bundling DM Sans is a licensing
decision, not a layout one -- so the sizes, weights and line heights
land on the system face.

🔴 The bubble corners are asymmetric and that is their whole character:
radius 16 everywhere except one corner at 2, which points at its author
-- bottom-right on a message you sent, top-left on one you received.
Rounding all four equally is what makes a chat app look like every
other chat app, and it is exactly what gets lost when a spec is taken
from a screenshot. Only reading the per-corner radii out of the JSON
found it; the symbol's fixedRadius reports a single number and is
misleading.

Conversation rows now have the kit's 48pt avatar, 16pt gutter and
16/14 type. Android had no avatar at all -- a coloured dot beside two
lines of text -- and no amount of adjusting the type would have made
that read like the design.

The Android chat screen needed more than bubbles. Back and the name sat
on one line, Call on a line of its own beneath, and the identity panel
under that, so a third of the screen was chrome before a message. One
bar now. The composer was an outlined field with two labelled buttons
beside it, which was the last part of the app that looked like a form;
it is the kit's pill, built on BasicTextField because a Material text
field reserves room for a floating label and will not go below 56dp
whatever shape it is given.

The timestamp moved outside the bubble, where the kit puts it. Inside,
a one-word message became a tall two-line box -- "sup" rendered taller
than it was wide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 13:49:37 -04:00
co-authored by Claude Opus 5
parent e46d51430a
commit b48d71a75a
2 changed files with 204 additions and 45 deletions
@@ -3,6 +3,10 @@ package io.flamenet.messenger.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.Surface
import androidx.compose.material3.ButtonDefaults
import androidx.compose.foundation.layout.height
@@ -57,6 +61,7 @@ import io.flamenet.client.IdentityTrust
import io.flamenet.e2e.SafetyNumber
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -171,22 +176,33 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
// and start scrolling only once the list is actually long.
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp).testTag("buddies")) {
items(phase.buddies, key = { it.userId }) { buddy ->
val name = buddy.displayName.ifBlank { buddy.handle }
.ifBlank { "user ${buddy.userId}" }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.openChat(buddy.userId, buddy.displayName) }
.padding(vertical = 14.dp)
// Half the kit's 24dp row gap on each side, which makes the
// 72dp pitch it draws between 48dp rows.
.padding(vertical = Kit.rowGap / 2)
.testTag("buddy-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
PresenceDot(buddy.presence)
// 🔴 **An avatar, which this row did not have.** The kit's list
// is built around a 48dp avatar and a 16dp gutter; without it
// the row was a coloured dot beside two lines of text, and no
// amount of adjusting the type would have made it read like
// the design. Initials rather than a photo, because the relay
// stores no avatars and inventing a placeholder face would be
// worse than the letters.
Avatar(name = name, online = buddy.presence.equals("online", ignoreCase = true))
Column(modifier = Modifier.weight(1f)) {
Text(
buddy.displayName.ifBlank { buddy.handle }.ifBlank { "user ${buddy.userId}" },
style = MaterialTheme.typography.bodyLarge,
name,
fontSize = 16.sp,
fontWeight = if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread)
FontWeight.Bold else null,
FontWeight.Bold else FontWeight.Medium,
)
// The handle beside the display name, because the handle is
// what you tell someone else to type.
@@ -195,7 +211,8 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
buddy.handle.takeIf { it.isNotBlank() },
buddy.presence.takeIf { it.isNotBlank() },
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
fontSize = 14.sp,
color = Palette.SecondaryText,
)
}
if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread) {
@@ -676,6 +693,50 @@ private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
}
}
/**
* A 48dp initials avatar with the presence dot on its corner.
*
* ⚠️ Two letters at most. Three-word display names produced a smear at this
* size, and the point of initials is that they are recognisable at a glance
* rather than complete.
*/
@Composable
private fun Avatar(name: String, online: Boolean) {
val initials = remember(name) {
name.trim().split(" ").filter { it.isNotBlank() }
.take(2).mapNotNull { it.firstOrNull() }
.joinToString("").uppercase().ifEmpty { "?" }
}
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(Kit.avatar)
.clip(CircleShape)
.background(Palette.AvatarFill),
contentAlignment = Alignment.Center,
) {
Text(
initials,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
if (online) {
Box(
modifier = Modifier
.size(Kit.presenceDot)
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.padding(2.dp)
.clip(CircleShape)
.background(Palette.Teal),
)
}
}
}
@Composable
private fun PresenceDot(status: String) {
val colour = when (status.lowercase()) {
@@ -705,20 +766,42 @@ fun ColumnScope.ChatScreen(
if (chat.lines.isNotEmpty()) listState.animateScrollToItem(chat.lines.lastIndex)
}
// 🔴 **One bar, not three stacked rows.** Back and the name sat on one
// line, Call on a line of its own beneath it, and the identity panel
// under that — so a third of the conversation was chrome before a single
// message. The kit's conversation bar is 88 tall and holds all of it:
// back, the name over its subtitle, and the actions on the trailing edge.
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
TextButton(onClick = model::closeChat, modifier = Modifier.testTag("back")) { Text("Back") }
Column(modifier = Modifier.weight(1f)) {
Text(chat.title.ifBlank { "Conversation" },
style = MaterialTheme.typography.headlineSmall)
Text(
chat.title.ifBlank { "Conversation" },
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
// ⚠️ One subtitle slot, whichever fact applies. A group says how
// many people are in it; a one-to-one has nothing to say here yet.
chat.group?.let {
Text("${it.memberIds.size} members",
style = MaterialTheme.typography.bodySmall)
Text(
"${it.memberIds.size} members",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
}
// Calling is one-to-one. A group call is a different feature with
// different key handling, and an enabled-looking button that cannot
// work is worse than an absent one.
if (chat.peerId != null) {
TextButton(
onClick = { model.requestCall(chat.peerId) },
modifier = Modifier.testTag("start-call"),
) { Text("Call") }
}
chat.group?.let { group ->
TextButton(
onClick = { model.leaveGroup(group.id) },
@@ -729,16 +812,6 @@ fun ColumnScope.ChatScreen(
chat.group?.let { group -> GroupMembers(model, group, buddies) }
// Calling is one-to-one. A group call is a different feature with different
// key handling, and an enabled-looking button that cannot work is worse
// than an absent one.
if (chat.peerId != null) {
TextButton(
onClick = { model.requestCall(chat.peerId) },
modifier = Modifier.testTag("start-call"),
) { Text("Call") }
}
// Identity, directly under the name it belongs to. Groups have many
// identities and no single answer, so this is for one-to-one threads only.
if (chat.peerId != null) IdentityPanel(model, chat)
@@ -775,29 +848,58 @@ fun ColumnScope.ChatScreen(
)
}
// The kit's composer: a 50-tall pill with radius 25, a leading attach
// control and the send action on the trailing edge. An outlined text field
// with two labelled buttons beside it was the one part of this screen that
// still looked like a form.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
TextButton(
onClick = { pickImage.launch("image/*") },
modifier = Modifier.testTag("attach"),
) { Text("Photo") }
OutlinedTextField(
) { Text("+", fontSize = 20.sp) }
// ⚠️ `BasicTextField` inside our own pill rather than a Material text
// field with its shape overridden: `OutlinedTextField` reserves room
// for a floating label and enforces a 56dp minimum, so it cannot be
// made 50 tall no matter what shape it is given.
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = Kit.pillHeight)
.clip(RoundedCornerShape(Kit.pillRadius))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = Kit.gutter, vertical = 14.dp),
contentAlignment = Alignment.CenterStart,
) {
if (draft.isEmpty()) {
Text("Type here …", fontSize = 14.sp, color = Palette.SecondaryText)
}
BasicTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("Message") },
modifier = Modifier.weight(1f).testTag("draft"),
textStyle = LocalTextStyle.current.copy(
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth().testTag("draft"),
)
}
Button(
onClick = {
model.sendMessage(draft)
draft = ""
},
enabled = draft.isNotBlank(),
modifier = Modifier.testTag("send"),
) { Text("Send") }
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(Kit.pillHeight).testTag("send"),
) { Text("→", fontSize = 20.sp) }
}
// Voice notes sit under the composer rather than inside it: recording
@@ -876,19 +978,51 @@ private fun Bubble(line: Session.Line, model: MessengerViewModel) {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(line.at))
}
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
// The kit insets a received bubble by 40 from the leading edge —
// the 32dp avatar plus its gutter — and lets a sent one run to the
// margin. The same number on the far side is what stops a long
// message spanning the full width, which is what makes a thread
// readable rather than a wall.
.padding(
start = if (line.mine) Kit.receivedInset else 0.dp,
end = if (line.mine) 0.dp else Kit.receivedInset,
),
horizontalArrangement = if (line.mine) Arrangement.End else Arrangement.Start,
) {
Column(horizontalAlignment = if (line.mine) Alignment.End else Alignment.Start) {
Text(
time,
fontSize = 10.sp,
color = Palette.SecondaryText,
modifier = Modifier.padding(horizontal = Kit.bubblePadding, vertical = 2.dp),
)
Column(
modifier = Modifier
.widthIn(max = 280.dp)
.clip(RoundedCornerShape(14.dp))
// 🔴 **One corner is square, and it points at its author.**
// Radius 16 everywhere except bottom-end on a message you
// sent and top-start on one you received. Rounding all four
// equally is what makes a chat app look like every other chat
// app, and it is the first thing lost when a spec is eyeballed
// from a screenshot rather than measured. See `DESIGN.md`.
.clip(
RoundedCornerShape(
topStart = if (line.mine) Kit.bubbleRadius else Kit.bubbleTail,
topEnd = Kit.bubbleRadius,
bottomEnd = if (line.mine) Kit.bubbleTail else Kit.bubbleRadius,
bottomStart = Kit.bubbleRadius,
)
)
.background(
if (line.mine) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant
)
.padding(horizontal = 12.dp, vertical = 8.dp),
.padding(Kit.bubblePadding),
) {
// ⚠️ The kit puts the time ABOVE the bubble, outside it. Inside, a
// one-word message became a tall two-line box — "sup" rendered
// taller than it was wide.
line.attachment?.let { ref ->
// One field, two very different things. Audio has no thumbnail
// to show and a duration that matters; an image has the
@@ -900,16 +1034,13 @@ private fun Bubble(line: Session.Line, model: MessengerViewModel) {
if (line.text.isNotEmpty()) {
Text(
line.text,
// 16sp regular, per the kit's Body Text 2.
fontSize = 16.sp,
color = if (line.mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
)
}
Text(
time,
style = MaterialTheme.typography.labelSmall,
color = (if (line.mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.7f),
)
}
}
}
}
@@ -80,6 +80,34 @@ object Radius {
}
/** The 4dp spacing scale, matching `Theme.Space` on iOS. */
/**
* Measurements taken from the design kit, in the kit's own terms.
*
* 🔴 **Insets, never fixed widths.** The kit is drawn on a 375pt canvas and its
* numbers are absolute — a 327-wide row is 375 minus two 24pt margins. Copying
* 327 across would break the layout on every phone that is not an iPhone X.
* Everything here is an inset or an intrinsic size, with the width left to the
* layout. See `DESIGN.md`, which both apps read from.
*/
object Kit {
val margin = 24.dp
val rowHeight = 48.dp
val rowGap = 24.dp
val avatar = 48.dp
val bubbleAvatar = 32.dp
val gutter = 16.dp
val bubblePadding = 12.dp
val receivedInset = 40.dp
val pillHeight = 50.dp
val pillRadius = 25.dp
val icon = 24.dp
val presenceDot = 12.dp
/** Every bubble corner but the one that points at its author. */
val bubbleRadius = 16.dp
/** Not zero: a hard point reads as a rendering artefact at this size. */
val bubbleTail = 2.dp
}
object Space {
val xs = 4.dp
val sm = 8.dp