From 09a4fb90fc30f2a7fb9f0ea16f9f7dc78421aad3 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 28 Aug 2026 09:57:01 -0400 Subject: [PATCH] Emoji picker gains recents and grouping, matching iOS This was a flat grid while iOS kept a recents row and grouped the set the way people think about them - a gap found when a duplicate iOS picker nearly shadowed the better one. Two platforms offering the same emoji through different furniture is the drift nobody files a bug about and everybody notices. Mechanics mirror the iOS file: the last twelve picked, most recent first, in ordinary SharedPreferences - which emoji somebody reaches for is not a secret worth a vault entry, and the vault cannot be read before sign-in anyway. The set and its grouping are byte-identical to iOS's, with a note in both files to change them in the same sitting. Test tags are scoped by section, as on iOS, because the same emoji legitimately appears in Recent and in its own group. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/messenger/app/Pickers.kt | 101 ++++++++++++++---- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt index 5a31edc..e0b587e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt @@ -2,18 +2,27 @@ package io.flamenet.messenger.app import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -21,6 +30,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -85,45 +95,90 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) { * ⚠️ The panel stays open on tap. Somebody picking three in a row expects three * characters in the box, and closing on each tap would make that impossible. * - * ⚠️ A short curated list rather than every emoji Unicode defines. A full - * picker is a search field, skin-tone variants, category tabs and a recents - * list — a real feature, and one the system keyboard already ships. This is the - * common set within reach of a thumb; the keyboard remains the way to reach the - * rest. + * 🔴 **Recents and grouping, matching iOS.** This was a flat grid while iOS + * kept a recents row and grouped the set the way people think about them — + * found when a duplicate iOS picker nearly shadowed the better one. Two + * platforms offering the same emoji through different furniture is the drift + * nobody files a bug about and everybody notices. The mechanics mirror the + * iOS file: recents are the last twelve picked, most recent first, in ordinary + * SharedPreferences — which emoji somebody reaches for is not a secret worth a + * vault entry, and the vault cannot be read before sign-in anyway. + * + * ⚠️ Still a curated set, not all of Unicode. A full picker is search, skin + * tones and category tabs — a real feature the system keyboard already ships. */ @Composable fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 44.dp), + val context = LocalContext.current + val prefs = remember { + context.getSharedPreferences("flamenet-emoji", android.content.Context.MODE_PRIVATE) + } + var recents by remember { + mutableStateOf(prefs.getString("recent", "")!!.split(" ").filter { it.isNotBlank() }) + } + + fun pick(emoji: String) { + val next = (listOf(emoji) + recents.filter { it != emoji }).take(12) + recents = next + prefs.edit().putString("recent", next.joinToString(" ")).apply() + onPick(emoji) + } + + Column( modifier = Modifier .fillMaxWidth() - .heightIn(max = 200.dp) + .heightIn(max = 220.dp) .clip(RoundedCornerShape(Radius.large)) .background(Palette.SurfaceAlt) + .verticalScroll(rememberScrollState()) + .padding(Space.sm) .testTag("emoji-picker"), - contentPadding = androidx.compose.foundation.layout.PaddingValues(Space.sm), - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(Space.xs), ) { - items(EMOJI, key = { it }) { emoji -> + if (recents.isNotEmpty()) EmojiSection("Recent", recents, ::pick) + EMOJI_GROUPS.forEach { (name, emoji) -> EmojiSection(name, emoji, ::pick) } + } +} + +@Composable +private fun EmojiSection(title: String, emoji: List, onPick: (String) -> Unit) { + Text( + title, + fontSize = 11.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + color = Palette.SecondaryText, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(Space.xs), + ) { + emoji.forEach { e -> Box( modifier = Modifier - .padding(2.dp) .clip(RoundedCornerShape(Radius.small)) - .clickable { onPick(emoji) } - .testTag("emoji-pick-$emoji"), - contentAlignment = Alignment.Center, + .clickable { onPick(e) } + // Scoped by section, as on iOS: the same emoji can sit in + // Recent AND its own group, and a tag on the glyph alone is + // ambiguous to a test. + .testTag("emoji-$title-$e"), ) { - Text(emoji, fontSize = 24.sp, modifier = Modifier.padding(Space.sm)) + Text(e, fontSize = 24.sp, modifier = Modifier.padding(Space.xs)) } } } } -/** The common set, in the order people actually reach for them. */ -private val EMOJI = listOf( - "😀", "😂", "🥹", "😊", "😍", "😘", "😎", "🤔", "😐", "🙄", - "😴", "😭", "😡", "🥳", "🤝", "👍", "👎", "👌", "🙏", "👏", - "💪", "👋", "🫡", "🤷", "❤️", "🧡", "💚", "💙", "💜", "🖤", - "🔥", "⭐", "✨", "🎉", "🎂", "☕", "🍕", "🍺", "🚀", "✅", - "❌", "⚠️", "💤", "👀", "🐛", "📎", "📷", "🎤", "📞", "💬", +/** + * The same set as iOS, grouped the same way — kept in step on purpose. + * If one platform's list changes, change the other in the same sitting. + */ +private val EMOJI_GROUPS = listOf( + "Smileys" to listOf("😀", "😂", "🙂", "😉", "😍", "🤔", "😅", "😭", "😡", "🥳", + "😴", "🤯", "🫠", "😬", "🙃", "😎"), + "Gestures" to listOf("👍", "👎", "👋", "🙏", "👏", "🤝", "💪", "🫶", "✌️", "🤞"), + "Hearts" to listOf("❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "💔", "✨", "🔥"), + "Things" to listOf("✅", "❌", "⚠️", "📌", "📎", "🔒", "🔑", "☕", "🍕", "🎉", + "🎂", "🚀", "🌧️", "☀️", "🌙", "⭐"), )