diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index b86f84d..772288e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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( - value = draft, - onValueChange = { draft = it }, - placeholder = { Text("Message") }, - modifier = Modifier.weight(1f).testTag("draft"), - ) + ) { 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 }, + 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,17 +1034,14 @@ 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), - ) } + } } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt index 2233379..d30b10e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt @@ -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