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:
co-authored by
Claude Opus 5
parent
7218a9b175
commit
0a452b9657
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user