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:
co-authored by
Claude Opus 5
parent
e46d51430a
commit
b48d71a75a
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user