Controls, not a page of orange words

Reported as "looks like a bunch of text links", with the honest caveat
of not knowing whether that is normal for Android. It is not, and it is
not the kit either: almost every action in the app was a bare
TextButton -- Back, Call, Safety number, Record a voice note, Mark all
read, Look up, Leave, Create group, and the four presence states -- all
the same undifferentiated orange text with nothing around it. That is
not an Android convention; it is the absence of one.

The kit ships three buttons, all 64 tall at radius 32 with a 14 medium
label: a filled primary, an outlined secondary, a grey tertiary. Plus a
small chip for something sitting in a row of its peers.

Presence was the worst of it -- four orange words that gave no sign of
being a single choice with one answer; the selected one was merely
bold. Chips now. Accepting a changed identity key is the most
consequential tap in the app and sat as matching orange text beside
"Show number", so the two read as interchangeable; it is an outlined
danger button now and the other is a chip.

Bar actions became glyphs in tappable circles with real accessibility
labels, rather than words. No icon set is pulled in for eight buttons.

Also in this pass: section headings shrank to 12sp labels and their
buttons moved into the app bar as icons. Two heading rows -- "Groups /
New" and "Contacts / Add" -- took the top third of a screen whose whole
content was two rows, and the kit's list has no headings at all.

iOS gets the same treatment: the tab bar is the kit's floating pill
rather than a TabView strip (PillTabBar records what the system bar was
doing for us that now has to be done by hand, including the one thing
that does not come back -- scroll-to-top), the list screen lost its
cards, and settings moved off .insetGrouped, which draws every section
as a floating card, onto plain rows separated by the kit's 16pt band.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 14:15:11 -04:00
co-authored by Claude Opus 5
parent 7218a9b175
commit 0a452b9657
3 changed files with 491 additions and 211 deletions
@@ -3,6 +3,9 @@ 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.semantics.semantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.material3.HorizontalDivider
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.foundation.text.BasicTextField
@@ -79,20 +82,24 @@ fun ColumnScope.BuddyList(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
query: String = "",
/**
* Whether the add-a-contact form is open.
*
* 🔴 **Hoisted out of this section on purpose.** It was a heading row with
* its own button — "Contacts" beside "Add" — and so was Groups, and the
* two of them ate the top third of a screen whose whole content was two
* rows. The kit puts actions in the app bar as icons and gives its list no
* headings at all. The state has to live up there with the button now.
*/
adding: Boolean = false,
onAddingChange: (Boolean) -> Unit = {},
) {
var adding by remember { mutableStateOf(false) }
var username by remember { mutableStateOf("") }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Contacts", style = MaterialTheme.typography.titleMedium)
TextButton(onClick = { adding = !adding }, modifier = Modifier.testTag("add-contact")) {
Text(if (adding) "Cancel" else "Add")
}
}
// ⚠️ A quiet label, not a heading. The kit's messages list has no section
// headings; ours holds two kinds of thing and would be a guessing game
// without them, so they are as small as they can be and still do the job.
SectionLabel("Contacts")
if (adding) {
OutlinedTextField(
@@ -109,11 +116,13 @@ fun ColumnScope.BuddyList(
// ⚠️ **Look up first, then add.** Adding straight from a typed string
// means a typo silently becomes a contact who is a stranger, and the
// first thing sent to them is a message meant for somebody else.
TextButton(
KitPrimaryButton(
text = "Look up",
onClick = { model.lookupMember(username) },
enabled = username.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("lookup-contact"),
) { Text("Look up") }
modifier = Modifier.fillMaxWidth(),
tag = "lookup-contact",
)
phase.lookupMiss?.let {
Text(
@@ -145,7 +154,7 @@ fun ColumnScope.BuddyList(
onClick = {
model.addBuddy(username.trim())
username = ""
adding = false
onAddingChange(false)
model.clearLookup()
},
enabled = phase.lookedUp != null && !phase.busy,
@@ -338,22 +347,14 @@ fun ColumnScope.GroupsSection(
phase: MessengerViewModel.Phase.SignedIn,
/** Filters this section from the one search box above the whole list. */
query: String = "",
/** See the note on [BuddyList]'s `adding` — the button lives in the bar. */
creating: Boolean = false,
onCreatingChange: (Boolean) -> Unit = {},
) {
var creating by remember { mutableStateOf(false) }
var name by remember { mutableStateOf("") }
var chosen by remember { mutableStateOf(setOf<Long>()) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Groups", style = MaterialTheme.typography.titleMedium)
TextButton(
onClick = { creating = !creating },
modifier = Modifier.testTag("new-group"),
) { Text(if (creating) "Cancel" else "New") }
}
SectionLabel("Groups")
if (creating) {
OutlinedTextField(
@@ -384,14 +385,16 @@ fun ColumnScope.GroupsSection(
Text(buddy.displayName.ifBlank { buddy.handle })
}
}
Button(
KitPrimaryButton(
text = "Create group",
onClick = {
model.createGroup(name.trim(), chosen.toList())
name = ""; chosen = emptySet(); creating = false
name = ""; chosen = emptySet(); onCreatingChange(false)
},
enabled = name.isNotBlank() && chosen.isNotEmpty() && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("create-group"),
) { Text("Create group") }
modifier = Modifier.fillMaxWidth(),
tag = "create-group",
)
}
// ⚠️ An empty list rendered NOTHING here — no guidance, and no way to tell
@@ -469,18 +472,16 @@ fun PresencePicker(model: MessengerViewModel, current: String) {
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// ⚠️ Chips, not four orange words in a row. As text buttons these read
// as four links and gave no sign that they were a single choice with
// one answer — the selected one was merely bold.
listOf("online", "away", "busy", "offline").forEach { status ->
TextButton(
KitChip(
text = status,
selected = status == current,
onClick = { model.setPresence(status) },
modifier = Modifier.testTag("presence-$status"),
) {
Text(
status,
fontWeight = if (status == current) FontWeight.Bold else null,
color = if (status == current) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
)
}
tag = "presence-$status",
)
}
}
}
@@ -490,44 +491,71 @@ fun ColumnScope.NotificationsScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
// Same bar grammar as the list screen: title at 24 bold, the one action on
// the trailing edge.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Notifications", style = MaterialTheme.typography.headlineSmall)
TextButton(
Text("Alerts", style = MaterialTheme.typography.headlineMedium)
KitChip(
text = "Mark all read",
selected = false,
onClick = model::markAllNotificationsRead,
modifier = Modifier.testTag("mark-all-read"),
) { Text("Mark all read") }
tag = "mark-all-read",
)
}
if (phase.notifications.isEmpty()) {
Text("Nothing new.", style = MaterialTheme.typography.bodyMedium)
Text("Nothing new.", fontSize = 14.sp, color = Palette.SecondaryText)
return
}
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f).testTag("notifications")) {
items(phase.notifications, key = { it.id }) { note ->
// 🔴 **Rows on the kit's 72dp pitch with an avatar, like every other list
// in the app.** This was a lazy list of two stacked text lines with an 8dp
// dot beside the unread ones — a different rhythm, a different type scale
// and a different unread marker from the contact list two taps away. One
// app, one row grammar.
//
// ⚠️ Plain rows in the page's own scroll, for the same reason the buddy
// list is: a lazy list inside a scrolling Column is measured with infinite
// height and crashes.
Column(modifier = Modifier.fillMaxWidth().testTag("notifications")) {
phase.notifications.forEach { note ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.markNotificationRead(note.id) }
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
.padding(vertical = Kit.rowGap / 2),
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
verticalAlignment = Alignment.CenterVertically,
) {
if (!note.read) {
Box(modifier = Modifier.size(8.dp).clip(CircleShape)
.background(MaterialTheme.colorScheme.primary))
}
Avatar(name = note.kind, online = false)
Column(modifier = Modifier.weight(1f)) {
Text(note.body.ifBlank { note.kind },
fontWeight = if (note.read) null else FontWeight.Bold)
Text(note.kind, style = MaterialTheme.typography.bodySmall)
Text(
note.body.ifBlank { note.kind },
fontSize = 16.sp,
fontWeight = if (note.read) FontWeight.Medium else FontWeight.Bold,
maxLines = 2,
)
Text(note.kind, fontSize = 14.sp, color = Palette.SecondaryText)
}
// The same unread marker the contact list uses, at the same
// size, on the same edge.
if (!note.read) {
Box(
modifier = Modifier
.size(Kit.presenceDot)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
}
}
Divider()
HorizontalDivider(
color = Palette.SecondaryText.copy(alpha = 0.16f),
modifier = Modifier.padding(start = Kit.avatar + Kit.gutter),
)
}
}
}
@@ -548,188 +576,207 @@ fun ColumnScope.AccountScreen(
var password by remember { mutableStateOf("") }
var confirmingDelete by remember { mutableStateOf(false) }
Text("Account", style = MaterialTheme.typography.headlineSmall)
Text("${phase.handle} · ${phase.relay}", style = MaterialTheme.typography.bodySmall)
Text("Device ${phase.deviceId}", style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("device-id"))
Spacer(Modifier.height(Space.lg))
// 🔴 **Cards, like every other screen in this app.** This one was a flat
// stack of full-width fields, buttons and paragraph-length explanations
// with `Divider()` between them — no grouping, so "change your password"
// and "delete your account" carried exactly the same visual weight, and a
// security setting sat in the same undifferentiated column as a text box.
// The Contacts screen was given this treatment and Account was missed.
SettingsCard {
AddressCard(fnid = phase.fnid, relay = phase.relay)
}
Text("Account", style = MaterialTheme.typography.headlineMedium)
Text(
"${phase.handle} · ${phase.relay}",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Text(
"Device ${phase.deviceId}",
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("device-id"),
)
Spacer(Modifier.height(Space.md))
SettingsCard {
Text("Profile", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(Space.sm))
// ⚠️ Moved here from the contacts screen. The kit's list screen has no
// presence control on it, and this is where the other facts about you
// already live — but it is a setting, not decoration, so it is kept
// rather than dropped along with the card it used to sit in.
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay) }
SectionBand()
SettingsSection {
PresencePicker(model, phase.presence)
Spacer(Modifier.height(Space.lg))
SettingsRow(label = "Display name") {}
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display name") },
placeholder = { Text("Display name", fontSize = 14.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("display-name"),
)
Spacer(Modifier.height(Space.sm))
Button(
KitPrimaryButton(
text = "Save display name",
onClick = { model.updateDisplayName(displayName.trim()); displayName = "" },
enabled = displayName.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-display-name"),
) { Text("Save display name") }
modifier = Modifier.fillMaxWidth(),
tag = "save-display-name",
)
Spacer(Modifier.height(Space.lg))
SettingsRow(
label = "Password",
description = "Changing it signs this device out — that is what changing it means.",
) {}
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("New password") },
placeholder = { Text("New password", fontSize = 14.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("new-password"),
)
Spacer(Modifier.height(Space.sm))
Button(
KitPrimaryButton(
text = "Change password",
onClick = { model.updatePassword(password); password = "" },
enabled = password.length >= 8 && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-password"),
) { Text("Change password") }
Spacer(Modifier.height(Space.sm))
Text(
"Changing it signs this device out — that is what changing it means.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(),
tag = "save-password",
)
}
// -- Security (§19) ------------------------------------------------------
Spacer(Modifier.height(Space.md))
SettingsCard {
Text("Security", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(Space.sm))
SectionBand()
SettingsSection {
if (phase.forkDetected) {
// Above the toggle, not below: this is a statement about the server
// the toggle configures, and it changes what the toggle means.
Text(
"⚠️ This relay showed two different key logs.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.testTag("fork-detected"),
)
Text(
"Two devices compared what this server told each of them about its key log, and the "
+ "answers cannot both be true. A server doing that can hand different people "
+ "different keys for the same person. Treat conversations here as compromised "
+ "until you have verified safety numbers in person.",
style = MaterialTheme.typography.bodySmall,
"Two devices compared what this server told each of them about its key log, and "
+ "the answers cannot both be true. A server doing that can hand different "
+ "people different keys for the same person. Treat conversations here as "
+ "compromised until you have verified safety numbers in person.",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(Space.sm))
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
SettingsRow(
label = "Require published keys",
description = "Refuse to send unless this server can prove the recipient's keys "
+ "appear in the public log it publishes. Older servers keep no log and every "
+ "message to them will fail.",
) {
Text("Require published keys", modifier = Modifier.weight(1f))
Switch(
checked = phase.requireKeyLog,
onCheckedChange = model::setRequireKeyLog,
modifier = Modifier.testTag("require-key-log"),
)
}
Text(
"Refuse to send unless this server can prove the recipient's keys appear in the public "
+ "log it publishes. Stronger, but older servers keep no log and every message to "
+ "them will fail. Your keys are always compared against what you saw before, "
+ "whether this is on or off.",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(Space.lg))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
SettingsRow(
label = "Sealed sender",
description = "Messages you send are addressed to the recipient without naming you. "
+ "Replacing your delivery key is the only way to stop someone abusing it — and "
+ "it cuts off every contact until you next message them.",
) {
Text("Sealed sender", modifier = Modifier.weight(1f))
TextButton(
onClick = model::rotateDeliveryKey,
modifier = Modifier.testTag("rotate-delivery-key"),
) { Text("New delivery key") }
KitChip(text = "New key", selected = false,
onClick = model::rotateDeliveryKey, tag = "rotate-delivery-key")
}
Text(
"Messages you send are addressed to the recipient without naming you, once they have "
+ "your delivery key. Replacing it is the only way to stop someone who is abusing "
+ "it — and it cuts off every contact until you next message them.",
style = MaterialTheme.typography.bodySmall,
)
}
// ⚠️ **The destructive pair, in their own card.** Sign-out lives here
// rather than under the contact list because this is where people look for
// it, and because it is destructive enough to sit beside the other
// destructive thing rather than under a list of friends. Separating the
// card is what stops "Sign out" reading like one more setting.
Spacer(Modifier.height(Space.md))
SettingsCard {
Button(
// ⚠️ **The destructive pair, in their own section.** They confirm in place
// rather than in a dialog: a dialog is dismissed reflexively; a button that
// changes to say what it is about to do is read.
SectionBand()
SettingsSection {
SettingsRow(
label = "Sign out",
description = "Erases this phone's keys and stored messages. They cannot be recovered.",
tag = "sign-out",
onClick = model::signOut,
modifier = Modifier.fillMaxWidth().testTag("sign-out"),
) { Text("Sign out") }
Spacer(Modifier.height(Space.sm))
Text(
"Signing out erases this phone's keys and stored messages. They cannot be recovered.",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(Space.lg))
Button(
SettingsRow(
label = if (confirmingDelete) "Really delete — this cannot be undone"
else "Delete account",
description = "Deletes the account on ${phase.relay} and erases this phone's keys "
+ "and messages.",
tag = "delete-account",
onClick = { if (confirmingDelete) model.deleteAccount() else confirmingDelete = true },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
modifier = Modifier.fillMaxWidth().testTag("delete-account"),
) {
Text(if (confirmingDelete) "Really delete — this cannot be undone" else "Delete account")
Text(
"›",
fontSize = 20.sp,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.height(Space.sm))
Text(
"Deletes the account on ${phase.relay} and erases this phone's keys and messages.",
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(Space.lg))
}
/**
* One group of settings on its own surface.
*
* ⚠️ Matches the cards the Contacts screen already uses rather than inventing a
* second card style — same shape, same elevation, same padding. Two kinds of
* card in one app reads as an unfinished redesign, which is what this was.
*/
@Composable
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
private fun SettingsSection(content: @Composable ColumnScope.() -> Unit) {
Surface(
shape = RoundedCornerShape(Radius.medium),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(Space.lg), content = content)
Column(Modifier.padding(vertical = Space.sm), content = content)
}
}
/**
* The 16pt band the kit puts between sections.
*
* ⚠️ Full bleed, which is why it takes a negative inset: the screen frame pads
* everything by 20 and this has to reach the edges to read as a band rather
* than as a wide grey row.
*/
@Composable
private fun SectionBand() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 0.dp)
.height(16.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
)
}
/**
* One settings row: label, an optional sentence under it, and one control on
* the trailing edge.
*
* ⚠️ The description belongs to the row, at 12pt, under its label — not as a
* paragraph floating between controls, which is what this screen had. A
* setting whose explanation runs longer than the section it lives in needs a
* shorter explanation, not more room.
*/
@Composable
private fun SettingsRow(
label: String,
description: String? = null,
tag: String? = null,
onClick: (() -> Unit)? = null,
trailing: @Composable (() -> 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)
description?.let {
Text(it, fontSize = 12.sp, color = Palette.SecondaryText)
}
}
trailing?.invoke()
}
}
@@ -777,6 +824,59 @@ private fun Avatar(name: String, online: Boolean) {
}
}
/**
* A section label at the smallest size that still separates two lists.
*
* ⚠️ 12sp muted, not a heading. A heading at 16 or 20 with a button beside it
* is a bar, and two bars for two short lists is most of a phone screen spent
* on saying what the next three rows are.
*/
/**
* A bar action: one glyph in a tappable circle, with the real label for
* anyone who cannot see it.
*
* ⚠️ Glyphs rather than a vector icon set. This app ships none, and adding a
* dependency for eight buttons is a supply chain for decoration. They are
* sized like the kit's 24dp icons.
*/
@Composable
private fun IconAction(
symbol: String,
label: String,
tag: String,
danger: Boolean = false,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(Kit.icon + Space.md)
.clip(CircleShape)
.clickable(onClick = onClick)
.semantics { contentDescription = label }
.testTag(tag),
contentAlignment = Alignment.Center,
) {
Text(
symbol,
fontSize = 20.sp,
color = if (danger) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text.uppercase(),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Palette.SecondaryText,
letterSpacing = 0.8.sp,
modifier = Modifier.padding(top = Space.md, bottom = Space.xs),
)
}
@Composable
private fun PresenceDot(status: String) {
val colour = when (status.lowercase()) {
@@ -816,7 +916,7 @@ fun ColumnScope.ChatScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
TextButton(onClick = model::closeChat, modifier = Modifier.testTag("back")) { Text("Back") }
IconAction(symbol = "‹", label = "Back", tag = "back", onClick = model::closeChat)
Column(modifier = Modifier.weight(1f)) {
Text(
chat.title.ifBlank { "Conversation" },
@@ -837,16 +937,21 @@ fun ColumnScope.ChatScreen(
// different key handling, and an enabled-looking button that cannot
// work is worse than an absent one.
if (chat.peerId != null) {
TextButton(
IconAction(
symbol = "☎",
label = "Call",
tag = "start-call",
onClick = { model.requestCall(chat.peerId) },
modifier = Modifier.testTag("start-call"),
) { Text("Call") }
)
}
chat.group?.let { group ->
TextButton(
IconAction(
symbol = "⏻",
label = "Leave group",
tag = "leave-group",
danger = true,
onClick = { model.leaveGroup(group.id) },
modifier = Modifier.testTag("leave-group"),
) { Text("Leave", color = MaterialTheme.colorScheme.error) }
)
}
}
@@ -897,10 +1002,8 @@ fun ColumnScope.ChatScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
TextButton(
onClick = { pickImage.launch("image/*") },
modifier = Modifier.testTag("attach"),
) { Text("+", fontSize = 20.sp) }
IconAction(symbol = "+", label = "Attach a photo", tag = "attach",
onClick = { pickImage.launch("image/*") })
// ⚠️ `BasicTextField` inside our own pill rather than a Material text
// field with its shape overridden: `OutlinedTextField` reserves room
@@ -981,13 +1084,18 @@ private fun ColumnScope.VoiceComposer(
val seconds = chat.recordingSeconds
if (seconds == null) {
TextButton(
// ⚠️ In the composer's grammar, not a sentence-length orange link
// hanging under it. It was the widest text on the screen and the least
// used control on it.
KitChip(
text = "🎤 Voice note",
selected = false,
onClick = {
if (granted) model.startRecording()
else ask.launch(Manifest.permission.RECORD_AUDIO)
},
modifier = Modifier.testTag("record-voice"),
) { Text("Record a voice note") }
tag = "record-voice",
)
} else {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -1000,14 +1108,10 @@ private fun ColumnScope.VoiceComposer(
"Recording ${"%.1f".format(seconds)}s",
modifier = Modifier.weight(1f).testTag("recording-timer"),
)
TextButton(
onClick = model::cancelRecording,
modifier = Modifier.testTag("cancel-voice"),
) { Text("Cancel") }
Button(
onClick = model::stopAndSendRecording,
modifier = Modifier.testTag("send-voice"),
) { Text("Send") }
KitChip(text = "Cancel", selected = false,
onClick = model::cancelRecording, tag = "cancel-voice")
KitChip(text = "Send", selected = true,
onClick = model::stopAndSendRecording, tag = "send-voice")
}
}
}
@@ -1122,15 +1226,23 @@ private fun ColumnScope.IdentityPanel(
+ "on a channel this app does not control before you accept.",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
// ⚠️ Accepting a changed key is the most consequential tap in
// the app. As matching orange text beside "Show number" the two
// looked interchangeable; the outline makes accepting a
// deliberate act and marks it as the dangerous one.
KitSecondaryButton(
text = "Accept new keys",
onClick = { model.acceptIdentityChange(peerId) },
modifier = Modifier.testTag("accept-identity"),
) { Text("Accept new keys") }
TextButton(
tag = "accept-identity",
danger = true,
)
KitChip(
text = if (showNumber) "Hide number" else "Show number",
selected = false,
onClick = { showNumber = !showNumber },
modifier = Modifier.testTag("show-safety-number"),
) { Text(if (showNumber) "Hide number" else "Show number") }
tag = "show-safety-number",
)
}
}
} else {
@@ -1149,10 +1261,12 @@ private fun ColumnScope.IdentityPanel(
modifier = Modifier.weight(1f).testTag("trust-${chat.trust.name.lowercase()}"),
)
if (chat.safetyNumber != null) {
TextButton(
KitChip(
text = if (showNumber) "Hide number" else "Safety number",
selected = false,
onClick = { showNumber = !showNumber },
modifier = Modifier.testTag("show-safety-number"),
) { Text(if (showNumber) "Hide number" else "Safety number") }
tag = "show-safety-number",
)
}
}
}
@@ -1387,10 +1501,8 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
verticalAlignment = Alignment.CenterVertically,
) {
Text(remaining, style = MaterialTheme.typography.bodySmall)
TextButton(
onClick = model::closeStory,
modifier = Modifier.testTag("close-story"),
) { Text("Close") }
KitChip(text = "Close", selected = false,
onClick = model::closeStory, tag = "close-story")
}
}
}
@@ -0,0 +1,123 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* The kit's buttons.
*
* 🔴 **Because almost every action in this app was a bare `TextButton`** — an
* orange word with nothing around it. Back, Call, Safety number, Record a
* voice note, Mark all read, Look up, Leave, and the four presence states were
* all the same undifferentiated orange text, so the app read as a page of
* links rather than as a set of controls. That is not an Android convention;
* it is the absence of one.
*
* The kit ships three: a filled primary, an outlined secondary and a grey
* tertiary. All 64 tall at radius 32 — fully rounded — with a 14 medium label
* and 16 of horizontal padding. See `DESIGN.md`.
*
* ⚠️ Height 64 is the kit's, and it is deliberately large. Where a control has
* to sit inline next to text, use [KitChip] rather than shrinking one of these
* — a 64pt button squeezed to 32 is neither the kit's button nor a chip.
*/
object KitButton {
val height = 64.dp
val radius = 32.dp
private val padding = PaddingValues(horizontal = 16.dp)
}
@Composable
fun KitPrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
tag: String? = null,
) {
Button(
onClick = onClick,
enabled = enabled,
shape = RoundedCornerShape(KitButton.radius),
contentPadding = PaddingValues(horizontal = 16.dp),
modifier = modifier
.height(KitButton.height)
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
) {
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
/** Outlined. For an action that is real but not the one being encouraged. */
@Composable
fun KitSecondaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
tag: String? = null,
danger: Boolean = false,
) {
val colour =
if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
OutlinedButton(
onClick = onClick,
enabled = enabled,
shape = RoundedCornerShape(KitButton.radius),
border = BorderStroke(1.dp, colour),
colors = ButtonDefaults.outlinedButtonColors(contentColor = colour),
contentPadding = PaddingValues(horizontal = 16.dp),
modifier = modifier
.height(KitButton.height)
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
) {
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
/**
* A small pill for something that sits in a row of its peers — a presence
* state, a filter. Selected fills with the primary; the rest are the kit's
* neutral surface.
*
* ⚠️ The kit's own small pill (its "On" badge) is 28 tall; 36 here so a finger
* can hit it, which the kit's badge does not have to survive.
*/
@Composable
fun KitChip(
text: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
tag: String? = null,
) {
Button(
onClick = onClick,
shape = RoundedCornerShape(18.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary
else Palette.SecondaryText,
),
modifier = modifier
.height(36.dp)
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
) {
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
@@ -13,6 +13,10 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.HorizontalDivider
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.background
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.clickable
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -415,6 +419,8 @@ private fun ColumnScope.SignedIn(
phase: MessengerViewModel.Phase.SignedIn,
) {
var query by remember { mutableStateOf("") }
var addingContact by remember { mutableStateOf(false) }
var creatingGroup by remember { mutableStateOf(false) }
// 🔴 **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
@@ -428,7 +434,11 @@ private fun ColumnScope.SignedIn(
// kit has no such control on its list screen, and Account is where the
// other things about you already live.
// App bar, per the kit: title at 24 bold, actions on the trailing edge.
// 🔴 **The actions live here, in the bar.** They were two heading rows
// further down — "Groups" beside "New", "Contacts" beside "Add" — and on a
// screen whose entire content was two rows, those two bars took the top
// third. The kit's large-title bar carries its actions as 24dp icons on
// the trailing edge and its list has no headings at all.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -446,6 +456,18 @@ private fun ColumnScope.SignedIn(
modifier = Modifier.testTag("device-id"),
)
}
BarAction(
symbol = if (creatingGroup) "×" else "⊞",
label = "New group",
tag = "new-group",
onClick = { creatingGroup = !creatingGroup },
)
BarAction(
symbol = if (addingContact) "×" else "+",
label = "Add contact",
tag = "add-contact",
onClick = { addingContact = !addingContact },
)
}
phase.error?.let {
@@ -492,8 +514,31 @@ private fun ColumnScope.SignedIn(
HorizontalDivider(color = Palette.SecondaryText.copy(alpha = 0.16f))
GroupsSection(model, phase, query)
BuddyList(model, phase, query)
GroupsSection(model, phase, query, creatingGroup) { creatingGroup = it }
BuddyList(model, phase, query, addingContact) { addingContact = it }
}
/**
* One icon-sized action on the trailing edge of the bar.
*
* ⚠️ Glyphs rather than vector assets, because this app ships no icon set and
* pulling one in for four buttons is a dependency for decoration. They are
* sized and spaced like the kit's 24dp icons and carry the real label for
* anyone who cannot see them.
*/
@Composable
private fun BarAction(symbol: String, label: String, tag: String, onClick: () -> Unit) {
Box(
modifier = Modifier
.size(Kit.icon + Space.md)
.clip(CircleShape)
.clickable(onClick = onClick)
.semantics { contentDescription = label }
.testTag(tag),
contentAlignment = Alignment.Center,
) {
Text(symbol, fontSize = 20.sp, color = MaterialTheme.colorScheme.primary)
}
}