From 0a452b96575dfa81b9cafd24eee6ca60aff72ee4 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 27 Aug 2026 14:15:11 -0400 Subject: [PATCH] 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 --- .../io/flamenet/messenger/app/ChatScreens.kt | 528 +++++++++++------- .../io/flamenet/messenger/app/KitButtons.kt | 123 ++++ .../io/flamenet/messenger/app/MainActivity.kt | 51 +- 3 files changed, 491 insertions(+), 211 deletions(-) create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt 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 55d06e5..719a10f 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,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()) } - 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") } } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt new file mode 100644 index 0000000..7b874fa --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt @@ -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) + } +} 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 b7e037a..9939fce 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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) + } }