From 7295f43a13c0616a6c8f92b0a0feac2fa22879c7 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 27 Aug 2026 15:50:14 -0400 Subject: [PATCH] Android gets a contact screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”ด **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 --- .../flamenet/messenger/app/CrudSurfaceTest.kt | 17 +- .../io/flamenet/messenger/app/ChatScreens.kt | 42 ++- .../flamenet/messenger/app/ContactDetail.kt | 258 ++++++++++++++++++ .../io/flamenet/messenger/app/MainActivity.kt | 12 +- .../flamenet/messenger/app/ScreenshotTest.kt | 7 + 5 files changed, 305 insertions(+), 31 deletions(-) create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt index 1da288a..272f5e3 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt @@ -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 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 ccd7ef0..8e80e57 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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(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( diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt new file mode 100644 index 0000000..4db523a --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 8d06165..a7b7172 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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(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 } } /** diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt index e4bf629..23b42cf 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -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(