Android gets a contact screen

🔴 **There was none.** Everything you could do to a person was an inline
row that unfolded under their list entry — Block, Clear chat, Remove,
three text buttons in a line — with nowhere to explain what any of them
did and nowhere to put a safety number. iOS has the kit's version; this
is the same screen: a cover, a name plate, and a rail of actions
floating at the seam between them.

⚠️ The rail carries only actions that do something. The kit's has a
"personal page" icon; there is no such page here, and a fourth control
that opens nothing is worse than a rail of three.

⚠️ Presence sits on the name's own line, not the plate's trailing edge.
The rail floats over the right-hand third of the plate, so anything put
there is behind it — a thing only visible once rendered, and it was
caught on iOS the same way an hour earlier.

Removing a contact confirms in place rather than in a dialog, matching
the Account screen: a dialog is dismissed reflexively; a row that
changes to say what it is about to do is read.

🔴 The on-device test asserted `block-$id`, `clear-$id`, `remove-$id`,
which no longer exist — those actions moved off the row and are not
keyed by peer id any more. Updated to follow them, plus an assertion
that there is a way back, or the screen is a trap. That suite needs a
device and cannot run here, so it would have failed silently until
somebody next ran it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 15:50:14 -04:00
co-authored by Claude Opus 5
parent b39c992115
commit 7295f43a13
5 changed files with 305 additions and 31 deletions
@@ -91,12 +91,21 @@ class CrudSurfaceTest {
compose.onNodeWithTag("confirm-contact").performClick()
awaitNode(hasText(peerHandle, substring = true), 60_000)
// Read the id the row was given, then open its actions.
// Read the id the row was given, then open the contact screen.
//
// ⚠️ The actions moved OFF the row. They were three text buttons that
// unfolded under the entry, tagged per peer id; they are now the
// contact screen's rail and rows, which exist once and are not keyed
// by id. The assertions follow them rather than the old names.
val peerId = peerIdFromTags()
compose.onNodeWithTag("contact-menu-$peerId").performClick()
compose.onNodeWithTag("block-$peerId").assertExists()
compose.onNodeWithTag("clear-$peerId").assertExists()
compose.onNodeWithTag("remove-$peerId").assertExists()
compose.onNodeWithTag("contact-message").assertExists()
compose.onNodeWithTag("contact-call").assertExists()
compose.onNodeWithTag("contact-block").assertExists()
compose.onNodeWithTag("contact-clear").assertExists()
compose.onNodeWithTag("contact-remove").assertExists()
// And a way back, or the screen is a trap.
compose.onNodeWithTag("contact-back").assertExists()
}
@Test
@@ -93,6 +93,8 @@ fun ColumnScope.BuddyList(
*/
adding: Boolean = false,
onAddingChange: (Boolean) -> Unit = {},
/** Opens the contact screen; the list itself owns no navigation. */
onOpenProfile: (Long) -> Unit = {},
) {
var username by remember { mutableStateOf("") }
@@ -181,7 +183,6 @@ fun ColumnScope.BuddyList(
return
}
var expanded by remember { mutableStateOf<Long?>(null) }
// ⚠️ heightIn, not weight(1f). Inside its card `weight` always expands to
// the full height it is offered, so a single contact still reserved a
@@ -261,31 +262,16 @@ fun ColumnScope.BuddyList(
}
// Everything you can do to a contact, reachable without a
// long-press nobody discovers.
TextButton(
onClick = { expanded = if (expanded == buddy.userId) null else buddy.userId },
modifier = Modifier.testTag("contact-menu-${buddy.userId}"),
) { Text(if (expanded == buddy.userId) "Close" else "⋯") }
}
if (expanded == buddy.userId) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val isBlocked = buddy.userId in phase.blocked
TextButton(
onClick = { model.setBlocked(buddy.userId, !isBlocked) },
modifier = Modifier.testTag("block-${buddy.userId}"),
) { Text(if (isBlocked) "Unblock" else "Block") }
TextButton(
onClick = { model.clearHistory(io.flamenet.e2e.Vault.threadForUser(buddy.userId)) },
modifier = Modifier.testTag("clear-${buddy.userId}"),
) { Text("Clear chat") }
TextButton(
onClick = { model.removeBuddy(buddy.userId); expanded = null },
modifier = Modifier.testTag("remove-${buddy.userId}"),
) { Text("Remove", color = MaterialTheme.colorScheme.error) }
}
// 🔴 Opens the contact screen. This used to unfold a row of
// three text buttons under the entry — Block, Clear chat,
// Remove — with nowhere to explain what any of them did and
// nowhere to put a safety number.
IconAction(
symbol = "⋯",
label = "About $name",
tag = "contact-menu-${buddy.userId}",
onClick = { onOpenProfile(buddy.userId) },
)
}
Divider()
}
@@ -777,6 +763,10 @@ private fun SettingsSection(content: @Composable ColumnScope.() -> Unit) {
* everything by 20 and this has to reach the edges to read as a band rather
* than as a wide grey row.
*/
/** ⚠️ Internal, not private: the contact detail uses the same band. */
@Composable
internal fun SectionBandPublic() = SectionBand()
@Composable
private fun SectionBand() {
Box(
@@ -0,0 +1,258 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.client.MessengerApi
/**
* One contact, as the kit draws it: a cover, a name plate, and a rail of
* actions floating at the seam between them.
*
* 🔴 **Android had no contact screen at all.** Everything you could do to a
* person was an inline row that unfolded under their list entry — Block, Clear
* chat, Remove, three text buttons in a line, with no room for anything that
* needed explaining and no place to put a safety number. iOS has the kit's
* version; this is the same screen.
*
* ⚠️ The rail carries only actions that do something. The kit's has a
* "personal page" icon; there is no such page here, and a fourth control that
* opens nothing is worse than a rail of three.
*/
@Composable
fun ColumnScope.ContactDetail(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
buddy: MessengerApi.Buddy,
onClose: () -> Unit,
) {
var confirmingRemove by remember { mutableStateOf(false) }
val isBlocked = buddy.userId in phase.blocked
val name = buddy.displayName.ifBlank { buddy.handle }.ifBlank { "user ${buddy.userId}" }
val initials = remember(name) {
name.trim().split(" ").filter { it.isNotBlank() }
.take(2).mapNotNull { it.firstOrNull() }.joinToString("").uppercase().ifEmpty { "?" }
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(Kit.icon + Space.md)
.clip(CircleShape)
.clickable(onClick = onClose)
.semantics { contentDescription = "Back" }
.testTag("contact-back"),
contentAlignment = Alignment.Center,
) {
Text("‹", fontSize = 20.sp, color = MaterialTheme.colorScheme.primary)
}
Spacer(Modifier.weight(1f))
}
Box(modifier = Modifier.fillMaxWidth()) {
Column {
// The cover. A photo in the kit; we hold no avatars, so it is this
// person's initials on a wash of the accent — colourless, but at
// least theirs, rather than a stock face standing in for them.
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Text(
initials,
fontSize = 64.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
// Name plate: 60 tall, radius 16, on the kit's neutral fill.
Row(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.clip(RoundedCornerShape(Radius.medium))
.background(Palette.SurfaceAlt)
.padding(horizontal = Kit.margin),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(name, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1)
// ⚠️ Presence goes on THIS line, not the trailing edge —
// the rail floats over the plate's right-hand third, so
// anything put there is behind it.
Row(verticalAlignment = Alignment.CenterVertically) {
if (buddy.presence.equals("online", true)) {
Box(Modifier.size(8.dp).clip(CircleShape).background(Palette.Teal))
Spacer(Modifier.width(6.dp))
}
Text(
listOfNotNull(
buddy.handle.takeIf { it.isNotBlank() && !it.equals(name, true) }
?.let { "@$it" },
buddy.presence.takeIf { it.isNotBlank() },
).joinToString(" · "),
fontSize = 12.sp,
color = Palette.SecondaryText,
maxLines = 1,
)
}
}
// Reserve the rail's width so a long name never runs under it.
Spacer(Modifier.width(60.dp + Space.md))
}
}
// The rail, overlapping the seam on purpose — that is what stops the
// cover reading as a dead banner.
Column(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 120.dp, end = Kit.margin)
.width(60.dp)
.clip(RoundedCornerShape(30.dp))
.background(
if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary
),
) {
RailButton("✉", "Message", "contact-message", enabled = !isBlocked) {
model.openChat(buddy.userId, buddy.displayName); onClose()
}
RailDivider()
RailButton("☎", "Call", "contact-call", enabled = !isBlocked) {
model.requestCall(buddy.userId)
}
RailDivider()
RailButton(
if (isBlocked) "◯" else "⊘",
if (isBlocked) "Unblock" else "Block",
"contact-block",
) { model.setBlocked(buddy.userId, !isBlocked) }
}
}
Spacer(Modifier.height(Space.xl))
SectionBandPublic()
Column(Modifier.padding(vertical = Space.sm)) {
DetailRow("Username", buddy.handle.ifBlank { "—" })
DetailRow("User ID", buddy.userId.toString())
DetailRow("Status", buddy.presence.ifBlank { "unknown" })
}
SectionBandPublic()
Column(Modifier.padding(vertical = Space.sm)) {
DetailRow(
"Clear this conversation",
"Removes it from this phone only. Their copy is theirs.",
tag = "contact-clear",
) { model.clearHistory(io.flamenet.e2e.Vault.threadForUser(buddy.userId)) }
// ⚠️ Confirms in place rather than in a dialog. A dialog is dismissed
// reflexively; a row that changes to say what it is about to do is
// read. Same posture as the Account screen's two destructive rows.
DetailRow(
if (confirmingRemove) "Really remove — this cannot be undone" else "Remove contact",
"Takes them off your list. It does not block them.",
tag = "contact-remove",
danger = true,
) {
if (confirmingRemove) { model.removeBuddy(buddy.userId); onClose() }
else confirmingRemove = true
}
}
}
@Composable
private fun RailDivider() {
Box(Modifier.fillMaxWidth().height(1.dp).background(Color.White.copy(alpha = 0.5f)))
}
@Composable
private fun RailButton(
symbol: String,
label: String,
tag: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(60.dp)
.clickable(enabled = enabled, onClick = onClick)
.semantics { contentDescription = label }
.testTag(tag),
contentAlignment = Alignment.Center,
) {
Text(symbol, fontSize = 18.sp, color = Color.White.copy(alpha = if (enabled) 1f else 0.5f))
}
}
@Composable
private fun DetailRow(
label: String,
value: String,
tag: String? = null,
danger: Boolean = false,
onClick: (() -> Unit)? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.then(if (tag != null) Modifier.testTag(tag) else Modifier)
.padding(vertical = Kit.bubblePadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.md),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
label,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = if (danger) MaterialTheme.colorScheme.error else Palette.Ink,
)
if (onClick != null) {
Text(value, fontSize = 12.sp, color = Palette.SecondaryText)
}
}
if (onClick == null) {
Text(value, fontSize = 14.sp, color = Palette.SecondaryText)
}
}
}
@@ -487,6 +487,16 @@ internal fun ColumnScope.SignedIn(
var query by remember { mutableStateOf("") }
var addingContact by remember { mutableStateOf(false) }
var creatingGroup by remember { mutableStateOf(false) }
var openProfile by remember { mutableStateOf<Long?>(null) }
// ⚠️ The contact screen REPLACES the list rather than sitting under it,
// the same way an open chat does. A detail view stacked below a list is
// two screens fighting over one scroll.
val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } }
if (profile != null) {
ContactDetail(model, phase, profile) { openProfile = null }
return
}
// 🔴 **A list, not a stack of explanatory cards.** This screen used to be
// four cards — a presence picker, a paragraph about what stories are, the
@@ -580,7 +590,7 @@ internal fun ColumnScope.SignedIn(
HorizontalDivider(color = Palette.SecondaryText.copy(alpha = 0.16f))
GroupsSection(model, phase, query, creatingGroup) { creatingGroup = it }
BuddyList(model, phase, query, addingContact) { addingContact = it }
BuddyList(model, phase, query, addingContact, { addingContact = it }) { openProfile = it }
}
/**
@@ -104,6 +104,13 @@ class ScreenshotTest {
androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) }
}
/** One contact, the kit's way: cover, name plate, floating action rail. */
@Test fun contactDetail() = shootFullScreen("screen-contact") { model, phase ->
androidx.compose.foundation.layout.Column {
ContactDetail(model, phase, phase.buddies[1]) {}
}
}
/** A call in progress — renderable because the look takes plain values. */
@Test fun callActive() = shoot("screen-call-active") {
CallScreenBody(