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 772288e..55d06e5 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -75,7 +75,11 @@ import androidx.compose.foundation.layout.heightIn * else's whereabouts, and this product should not make one it cannot support. */ @Composable -fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn) { +fun ColumnScope.BuddyList( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, + query: String = "", +) { var adding by remember { mutableStateOf(false) } var username by remember { mutableStateOf("") } @@ -84,7 +88,7 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("Contacts", style = MaterialTheme.typography.headlineSmall) + Text("Contacts", style = MaterialTheme.typography.titleMedium) TextButton(onClick = { adding = !adding }, modifier = Modifier.testTag("add-contact")) { Text(if (adding) "Cancel" else "Add") } @@ -174,8 +178,23 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P // the full height it is offered, so a single contact still reserved a // fixed slab of empty white. A max lets the card size to its content // and start scrolling only once the list is actually long. - LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp).testTag("buddies")) { - items(phase.buddies, key = { it.userId }) { buddy -> + // ๐Ÿ”ด **Plain rows in the page's own scroll, not a bounded lazy list.** + // The list lived in a card with `heightIn(max = 320.dp)` because a lazy + // list inside a scrolling Column is measured with infinite height and + // crashes. Rendering the rows directly sidesteps that entirely โ€” and the + // 320 cap was itself a compromise that made the list a small scrolling + // window inside a scrolling page, which is exactly what the kit's flat + // list is not. + // + // โš ๏ธ No recycling. Fine for a contact list of tens; if this ever holds + // hundreds it wants a single lazy list owning the whole screen's scroll, + // not a nested one. + val shown = phase.buddies.filter { + query.isBlank() || + it.displayName.contains(query, true) || it.handle.contains(query, true) + } + Column(modifier = Modifier.fillMaxWidth().testTag("buddies")) { + shown.forEach { buddy -> val name = buddy.displayName.ifBlank { buddy.handle } .ifBlank { "user ${buddy.userId}" } Row( @@ -317,6 +336,8 @@ private fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerV fun ColumnScope.GroupsSection( model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn, + /** Filters this section from the one search box above the whole list. */ + query: String = "", ) { var creating by remember { mutableStateOf(false) } var name by remember { mutableStateOf("") } @@ -327,7 +348,7 @@ fun ColumnScope.GroupsSection( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("Groups", style = MaterialTheme.typography.headlineSmall) + Text("Groups", style = MaterialTheme.typography.titleMedium) TextButton( onClick = { creating = !creating }, modifier = Modifier.testTag("new-group"), @@ -388,21 +409,32 @@ fun ColumnScope.GroupsSection( ) } - phase.groups.forEach { group -> + val shownGroups = phase.groups.filter { + query.isBlank() || it.name.contains(query, true) + } + shownGroups.forEach { group -> val thread = io.flamenet.e2e.Vault.threadForGroup(group.id) Row( modifier = Modifier .fillMaxWidth() .clickable { model.openGroup(group) } - .padding(vertical = 12.dp) + // โš ๏ธ Same 72dp pitch as a contact row, from the same tokens. A + // group row measured differently sat at a different rhythm in + // the same list, which reads as two lists that happen to + // follow each other. + .padding(vertical = Kit.rowGap / 2) .testTag("group-${group.id}"), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(Kit.gutter), ) { + // The same avatar shape as a contact, so the two kinds of row read + // as one list. A group has no presence, so no dot. + Avatar(name = group.name.ifBlank { "Group" }, online = false) Column(modifier = Modifier.weight(1f)) { Text( group.name.ifBlank { "Group" }, - fontWeight = if (thread in phase.unread) FontWeight.Bold else null, + fontSize = 16.sp, + fontWeight = if (thread in phase.unread) FontWeight.Bold else FontWeight.Medium, ) Text( // "You" first: you are a member of your own group and are @@ -414,7 +446,9 @@ fun ColumnScope.GroupsSection( ?.let { b -> b.displayName.ifBlank { b.handle } } ?: "user $id" }, - style = MaterialTheme.typography.bodySmall, + fontSize = 14.sp, + color = Palette.SecondaryText, + maxLines = 1, ) } if (thread in phase.unread) { @@ -535,6 +569,12 @@ fun ColumnScope.AccountScreen( 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. + PresencePicker(model, phase.presence) + Spacer(Modifier.height(Space.lg)) OutlinedTextField( value = displayName, onValueChange = { displayName = it }, @@ -1245,44 +1285,78 @@ fun ColumnScope.StoryStrip( LaunchedEffect(Unit) { model.refreshStories() } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("Stories", style = MaterialTheme.typography.titleMedium) - TextButton( - onClick = { pickStory.launch("image/*") }, - modifier = Modifier.testTag("post-story"), - ) { Text("Post") } - } - - if (phase.stories.isEmpty()) { - Text( - "Nothing here. A story goes to your contacts and disappears after a day โ€” on your " - + "device and theirs, because their app deletes it too. It is not a secret you " - + "can take back.", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.testTag("stories-empty"), - ) - return - } - + // ๐Ÿ”ด **A strip of faces, which is what the kit's "Recent" row is.** This + // was a heading, a Post button, and โ€” with nothing posted, which is the + // normal state โ€” three lines of prose explaining what a story is. Prose + // where the design has a row of circles is the single loudest thing + // telling you this is not the same app. The explanation still exists; it + // moved to the one place it is actually needed, under the button that + // posts one. + // + // โš ๏ธ The add-circle is always first and always present, so the strip never + // collapses to nothing and there is always somewhere to tap. LazyRow( modifier = Modifier.fillMaxWidth().testTag("stories"), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(Space.md), ) { + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.testTag("post-story"), + ) { + Box( + modifier = Modifier + .size(Kit.avatar) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { pickStory.launch("image/*") }, + contentAlignment = Alignment.Center, + ) { + Text("+", fontSize = 22.sp, color = MaterialTheme.colorScheme.primary) + } + Text("Post", fontSize = 12.sp, color = Palette.SecondaryText) + } + } items(phase.stories, key = { it.id }) { story -> val label = phase.buddies.firstOrNull { it.userId == story.authorId } ?.let { it.displayName.ifBlank { it.handle } } ?: if (story.mine) "You" else "#${story.authorId}" - TextButton( - onClick = { model.openStory(story) }, + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.testTag("story-${story.id}"), ) { - // Unseen is marked rather than styled away: a strip where read - // and unread look alike is a strip nobody comes back to. - Text(if (story.seen) label else "โ€ข $label") + Box( + modifier = Modifier + .size(Kit.avatar) + .clip(CircleShape) + // Unseen is marked rather than styled away: a strip + // where read and unread look alike is a strip nobody + // comes back to. A ring, since there is no room for a + // bullet beside a 48dp circle. + .background( + if (story.seen) Palette.AvatarFill + else MaterialTheme.colorScheme.primary + ) + .padding(if (story.seen) 0.dp else 2.dp) + .clip(CircleShape) + .background(Palette.AvatarFill) + .clickable { model.openStory(story) }, + contentAlignment = Alignment.Center, + ) { + Text( + label.take(1).uppercase(), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } + Text( + label, + fontSize = 12.sp, + color = Palette.SecondaryText, + maxLines = 1, + modifier = Modifier.widthIn(max = Kit.avatar + 8.dp), + ) } } } 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 96288e1..b7e037a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -4,6 +4,16 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.draw.clip +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.Alignment import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxSize @@ -155,11 +165,16 @@ private fun Root(model: MessengerViewModel = viewModel()) { // BELOW the lazy list, not just the list itself. And // keep the buddy list bounded โ€” remove that height and // this does crash. + // โš ๏ธ Tighter than it was. Every section already carries + // its own padding, so a 12dp gap between them on top of + // that left the list airy in a way the kit is not โ€” its + // rows sit on a 72dp pitch with nothing extra between + // the groups of them. Column( modifier = Modifier .weight(1f) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(Space.sm), ) { when (current.screen) { MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current) @@ -347,8 +362,18 @@ private fun BottomNav( phase: MessengerViewModel.Phase.SignedIn, ) { val unreadNotes = phase.notifications.count { !it.read } + // ๐Ÿ”ด **A floating pill, not a full-width strip.** The kit's tab bar is a + // 328x50 rounded rectangle inset from both edges, sitting inside an 84-tall + // band โ€” it reads as a control resting on the page rather than as the + // bottom of the window. This is the single most recognisable thing about + // the design and the app had a plain row of text buttons. Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = Kit.pillHeight) + .clip(RoundedCornerShape(Kit.pillRadius)) + .background(MaterialTheme.colorScheme.surfaceVariant), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS, @@ -389,77 +414,89 @@ private fun ColumnScope.SignedIn( model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn, ) { - Text(phase.handle.ifBlank { "Signed in" }, style = MaterialTheme.typography.headlineMedium) - Text("${phase.relay} ยท ${phase.deviceId.take(8)}", - style = MaterialTheme.typography.bodySmall, - color = Palette.SecondaryText, - modifier = Modifier.testTag("device-id")) + var query by remember { mutableStateOf("") } + + // ๐Ÿ”ด **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 + // contacts, the groups โ€” each on its own white surface with a heading. The + // kit's messages screen is one flat list under a title, a search box and a + // strip of stories, and no amount of restyling the ROWS inside those cards + // was ever going to make it read like the design. The cards were the + // problem. See `DESIGN.md`. + // + // โš ๏ธ The presence picker moved to Account rather than being deleted: the + // 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. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + phase.handle.ifBlank { "Signed in" }, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + Text( + "${phase.relay} ยท ${phase.deviceId.take(8)}", + fontSize = 12.sp, + color = Palette.SecondaryText, + modifier = Modifier.testTag("device-id"), + ) + } + } - // โš ๏ธ **Screen level, not inside Contacts.** There is ONE error slot shared - // by everything on this screen, and it used to be rendered inside - // BuddyList โ€” so a story that failed to post, or a group that could not be - // created, appeared underneath the Contacts heading and read as a contacts - // problem. Observed with a rate-limited story post reported under Contacts. phase.error?.let { + // โš ๏ธ **Screen level, not inside Contacts.** There is ONE error slot + // shared by everything here, and it used to render inside the buddy + // list โ€” so a story that failed to post appeared under the Contacts + // heading and read as a contacts problem. Text( it, color = MaterialTheme.colorScheme.error, - modifier = Modifier.testTag("error").padding(top = Space.sm), + fontSize = 14.sp, + modifier = Modifier.testTag("error"), ) } - // Each section on its own card. โš ๏ธ The sections themselves are unchanged โ€” - // same composables, same testTags, same order โ€” because this is a skin over - // screens that work, and moving them would mean re-pointing the UI tests - // for a visual change. - Spacer(Modifier.height(Space.lg)) - Surface( - shape = RoundedCornerShape(Radius.medium), - color = MaterialTheme.colorScheme.surface, - shadowElevation = 2.dp, - modifier = Modifier.fillMaxWidth(), + // Search, as the kit's 48-tall pill. Filters what is already here; it does + // not ask the relay anything, so it costs nothing and needs no button. + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(Kit.pillRadius)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = Kit.gutter, vertical = 12.dp), + contentAlignment = Alignment.CenterStart, ) { - Column(Modifier.padding(Space.lg)) { PresencePicker(model, phase.presence) } + if (query.isEmpty()) { + Text("Search", fontSize = 14.sp, color = Palette.SecondaryText) + } + BasicTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + textStyle = LocalTextStyle.current.copy( + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth().testTag("search"), + ) } - Spacer(Modifier.height(Space.md)) - Surface( - shape = RoundedCornerShape(Radius.medium), - color = MaterialTheme.colorScheme.surface, - shadowElevation = 2.dp, - modifier = Modifier.fillMaxWidth(), - ) { - Column(Modifier.padding(Space.lg)) { StoryStrip(model, phase) } - } + StoryStrip(model, phase) - Spacer(Modifier.height(Space.md)) - // โš ๏ธ **Bounded on purpose.** BuddyList's LazyColumn carries `weight(1f)`, - // which used to resolve against the whole signed-in Column. Inside a card - // with no height of its own it resolves against infinity instead, so the - // contacts card grew to fill the screen and pushed Groups and the tab bar - // off the bottom entirely. A max height keeps the list scrollable inside - // its own card and leaves the rest of the screen reachable. - Surface( - shape = RoundedCornerShape(Radius.medium), - color = MaterialTheme.colorScheme.surface, - shadowElevation = 2.dp, - modifier = Modifier.fillMaxWidth(), - ) { - Column(Modifier.padding(Space.lg)) { BuddyList(model, phase) } - } + HorizontalDivider(color = Palette.SecondaryText.copy(alpha = 0.16f)) - Spacer(Modifier.height(Space.md)) - Surface( - shape = RoundedCornerShape(Radius.medium), - color = MaterialTheme.colorScheme.surface, - shadowElevation = 2.dp, - modifier = Modifier.fillMaxWidth(), - ) { - Column(Modifier.padding(Space.lg)) { GroupsSection(model, phase) } - } - Spacer(Modifier.height(Space.lg)) + GroupsSection(model, phase, query) + BuddyList(model, phase, query) } + @Composable private fun ColumnScope.VaultUnreadable( model: MessengerViewModel, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt index d30b10e..93ee3f1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt @@ -132,16 +132,38 @@ private val FlamenetShapes = Shapes( * cover redistribution, and the platform default already reads well at these * sizes. If a face is chosen later it goes here and in the iOS `Theme.Font`. */ +/** + * The design kit's type scale, applied globally. + * + * ๐Ÿ”ด **This is the single biggest reason the app did not look like the kit.** + * Material 3's defaults are considerably larger than the kit's, and every + * screen inherits them โ€” so a section heading rendered at 24sp where the kit + * says 16, and a body line at 16 where the kit says 14. Restyling individual + * screens against those defaults is pushing against the whole theme; setting + * the scale here fixes every screen at once, including ones nobody has + * touched yet. + * + * Names on the left are Material's; sizes on the right are the kit's. See + * `DESIGN.md` โ€” H1 40, H2 32, H3 24, H4 20, Sub 16/14/12, Body 16/14, + * Description 12/10. + */ private val FlamenetTypography = Typography().let { base -> base.copy( headlineLarge = base.headlineLarge.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold), - headlineMedium = base.headlineMedium.copy(fontSize = 26.sp, fontWeight = FontWeight.Bold), - titleLarge = base.titleLarge.copy(fontSize = 22.sp, fontWeight = FontWeight.SemiBold), - titleMedium = base.titleMedium.copy(fontSize = 17.sp, fontWeight = FontWeight.SemiBold), + headlineMedium = base.headlineMedium.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold), + headlineSmall = base.headlineSmall.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold), + titleLarge = base.titleLarge.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold), + // Sub 3 โ€” what a section label is, not a headline. + titleMedium = base.titleMedium.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium), + titleSmall = base.titleSmall.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium), bodyLarge = base.bodyLarge.copy(fontSize = 16.sp), - bodyMedium = base.bodyMedium.copy(fontSize = 15.sp), - labelLarge = base.labelLarge.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold), - labelSmall = base.labelSmall.copy(letterSpacing = 0.6.sp, fontWeight = FontWeight.SemiBold), + bodyMedium = base.bodyMedium.copy(fontSize = 14.sp), + bodySmall = base.bodySmall.copy(fontSize = 12.sp), + labelLarge = base.labelLarge.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium), + labelMedium = base.labelMedium.copy(fontSize = 12.sp), + // Description 2 โ€” the timestamp above a bubble. + labelSmall = base.labelSmall.copy(fontSize = 10.sp, letterSpacing = 0.sp, + fontWeight = FontWeight.Normal), ) }