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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-28 09:57:01 -04:00
co-authored by Claude Opus 5
parent a68c9212d9
commit 09a4fb90fc
@@ -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<String>, 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("✅", "❌", "⚠️", "📌", "📎", "🔒", "🔑", "☕", "🍕", "🎉",
"🎂", "🚀", "🌧️", "☀️", "🌙", "⭐"),
)