diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index b2b0235051..20ec22af88 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -1012,6 +1012,7 @@
Reactions
Search
Clear search
+ Choose skin tone
Add reaction
Unknown Contact
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
index 94b0bf6d97..714c292788 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
@@ -369,6 +369,7 @@ private fun FlowReactionPickerScreen(messageId: Long) {
ReactionPickerSheet(
searchState = state.searchFieldState,
sections = state.sections,
+ tones = state.tones,
loaded = state.loaded,
onSelected = { emoji ->
chatViewModel.dispatchEvent(
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt
index dd71fa4e8b..41a28be81f 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt
@@ -4,20 +4,24 @@ import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.viewModelScope
import com.flipcash.libs.coroutines.DispatcherProvider
-import com.getcode.libs.emojis.reactions.EmojiDrawability
+import com.getcode.libs.emojis.reactions.EmojiCatalog
import com.getcode.libs.emojis.reactions.EmojiCatalogLoader
+import com.getcode.libs.emojis.reactions.EmojiDrawability
import com.getcode.libs.emojis.reactions.EmojiPickerModel
import com.getcode.libs.emojis.reactions.RecentReactionsStore
import com.getcode.view.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.withContext
/**
* The full emoji picker's search and sections. Scoped to the picker's own nav entry
@@ -26,7 +30,7 @@ import kotlinx.coroutines.flow.onEach
*/
@HiltViewModel
class EmojiPickerViewModel internal constructor(
- dispatchers: DispatcherProvider,
+ private val dispatchers: DispatcherProvider,
private val emojiCatalogLoader: EmojiCatalogLoader,
private val recentReactionsStore: RecentReactionsStore,
// A seam for tests: Robolectric's Paint reports no emoji glyphs, which would empty every section.
@@ -49,11 +53,36 @@ class EmojiPickerViewModel internal constructor(
val sections: List = emptyList(),
/** False until the first load lands, which is what the sheet's spinner waits on. */
val loaded: Boolean = false,
+ /** What a long-press on an emoji offers; see [EmojiPickerModel.toneOptions]. */
+ val tones: Map> = emptyMap(),
)
sealed interface Event {
/** Internal: the sections for the settled search text finished loading. */
- data class SectionsLoaded(val sections: List) : Event
+ data class SectionsLoaded(
+ val sections: List,
+ val tones: Map>,
+ ) : Event
+ }
+
+ private class CatalogInputs(
+ val catalog: EmojiCatalog,
+ val undrawable: Set,
+ val tones: Map>,
+ )
+
+ /**
+ * The catalog with what this device can't draw and the tone choices, worked out once per sheet:
+ * the glyph probe over all 3,944 entries is the costly part, and its answer can't change while
+ * the sheet is open.
+ */
+ private val catalogInputs = viewModelScope.async(dispatchers.Default, start = CoroutineStart.LAZY) {
+ val catalog = emojiCatalogLoader.load()
+ val undrawable = catalog.entries
+ .map { it.emoji }
+ .filterNot { EmojiDrawability.isDrawable(it, glyphProbe) }
+ .toSet()
+ CatalogInputs(catalog, undrawable, EmojiPickerModel.toneOptions(catalog, undrawable))
}
init {
@@ -62,30 +91,36 @@ class EmojiPickerViewModel internal constructor(
snapshotFlow { stateFlow.value.searchFieldState.text.toString() }
.distinctUntilChanged()
.debounce(150)
- .mapLatest { query -> Event.SectionsLoaded(sections(query)) }
+ .mapLatest { query -> sections(query) }
.onEach { dispatchEvent(it) }
.launchIn(viewModelScope)
}
/**
* The sections for [query] (recents row + one section per catalog category, or a single
- * search-results section — see [EmojiPickerModel.sections]). The loader caches the parsed
- * catalog, so reloading it per query is cheap after the first.
+ * search-results section — see [EmojiPickerModel.sections]), built on the default dispatcher
+ * so a keystroke's filtering stays off the main thread.
*/
- private suspend fun sections(query: String): List {
- val catalog = emojiCatalogLoader.load()
- val undrawable = catalog.entries
- .map { it.emoji }
- .filterNot { EmojiDrawability.isDrawable(it, glyphProbe) }
- .toSet()
- val recents = recentReactionsStore.rank(undrawable = undrawable)
- return EmojiPickerModel.sections(catalog = catalog, undrawable = undrawable, recents = recents, query = query)
+ private suspend fun sections(query: String): Event.SectionsLoaded = withContext(dispatchers.Default) {
+ val inputs = catalogInputs.await()
+ val recents = recentReactionsStore.rank(undrawable = inputs.undrawable)
+ Event.SectionsLoaded(
+ sections = EmojiPickerModel.sections(
+ catalog = inputs.catalog,
+ undrawable = inputs.undrawable,
+ recents = recents,
+ query = query,
+ ),
+ tones = inputs.tones,
+ )
}
companion object {
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
when (event) {
- is Event.SectionsLoaded -> { state -> state.copy(sections = event.sections, loaded = true) }
+ is Event.SectionsLoaded -> { state ->
+ state.copy(sections = event.sections, tones = event.tones, loaded = true)
+ }
}
}
}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt
index 5dedfa85f7..de592e7711 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt
@@ -4,6 +4,8 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
@@ -25,6 +27,8 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
@@ -34,6 +38,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -46,6 +51,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
@@ -59,11 +67,19 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupPositionProvider
+import androidx.compose.ui.window.PopupProperties
+import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge
import com.flipcash.features.messenger.R
import com.getcode.libs.emojis.reactions.EmojiCatalogEntry
import com.getcode.libs.emojis.reactions.EmojiPickerModel
@@ -84,16 +100,19 @@ import kotlinx.coroutines.launch
/**
* The full emoji picker (decision 5: opens as `ChatStep.ReactionPicker`, a [com.getcode.ui.navigation.HalfSheet]).
- * A search capsule (not auto-focused — iOS's picker doesn't auto-focus its search field either),
- * then every catalog category in a fixed 7-column grid behind a floating glass category bar, all
- * through one scrollable list so [AllowSheetExpansionWhenScrollable] sees a single scroll container.
- * Mirrors iOS `EmojiPickerSheet` (node 9768:1402, category bar node 9768:1624). There is no title —
- * iOS's picker doesn't have one either.
+ * A glass search capsule floating over the grid (not auto-focused — iOS's picker doesn't auto-focus
+ * its search field either), then every catalog category in a fixed 7-column grid behind a floating
+ * glass category bar, all through one scrollable list so [AllowSheetExpansionWhenScrollable] sees a
+ * single scroll container. Mirrors iOS `EmojiPickerSheet` (node 9768:1402, category bar node
+ * 9768:1624). There is no title — iOS's picker doesn't have one either.
+ *
+ * The grid shows base emoji only. Long-pressing one with skin tones ([tones]) offers them.
*/
@Composable
internal fun ReactionPickerSheet(
searchState: TextFieldState,
sections: List,
+ tones: Map>,
loaded: Boolean,
onSelected: (String) -> Unit,
onDismiss: () -> Unit,
@@ -102,25 +121,25 @@ internal fun ReactionPickerSheet(
AllowSheetExpansionWhenScrollable(gridState)
val query = searchState.text.toString()
+ val hazeState = rememberHazeState()
+ val glass = rememberLiquidGlass()
- Column(modifier = Modifier.fillMaxWidth()) {
- SearchField(
- state = searchState,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp)
- .padding(top = 16.dp, bottom = 12.dp),
- )
-
+ Box(modifier = Modifier.fillMaxWidth()) {
when {
!loaded -> {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Box(
+ modifier = Modifier.fillMaxSize().padding(top = SEARCH_FIELD_INSET),
+ contentAlignment = Alignment.Center,
+ ) {
CodeCircularProgressIndicator()
}
}
sections.isEmpty() && query.isNotBlank() -> {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Box(
+ modifier = Modifier.fillMaxSize().padding(top = SEARCH_FIELD_INSET),
+ contentAlignment = Alignment.Center,
+ ) {
Text(
text = stringResource(R.string.title_noSearchResults, query),
style = CodeTheme.typography.textMedium,
@@ -134,12 +153,43 @@ internal fun ReactionPickerSheet(
else -> {
EmojiGrid(
sections = sections,
+ tones = tones,
query = query,
gridState = gridState,
+ hazeState = hazeState,
+ glass = glass,
onSelected = onSelected,
)
}
}
+
+ // Over the grid rather than above it, so the emoji scroll up under the glass.
+ SearchField(
+ state = searchState,
+ hazeState = hazeState,
+ glass = glass,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(top = SEARCH_FIELD_TOP),
+ )
+ }
+}
+
+/**
+ * The glass both floating capsules are frosted with: a Haze blur of the grid beneath — Android's
+ * analogue of iOS's `.glassEffect` (see [com.flipcash.app.core.ui.NavigationBar] for the same
+ * pattern).
+ */
+@Composable
+private fun rememberLiquidGlass(): HazeBlurStyle {
+ val backdrop = CodeTheme.colors.background
+ return remember(backdrop) {
+ HazeBlurStyle {
+ blurRadius(32.dp)
+ backgroundColor(backdrop)
+ colorEffects(listOf(HazeColorEffect.tint(backdrop.copy(alpha = 0.72f))))
+ }
}
}
@@ -156,8 +206,11 @@ private sealed interface GridRow {
@Composable
private fun EmojiGrid(
sections: List,
+ tones: Map>,
query: String,
gridState: LazyListState,
+ hazeState: HazeState,
+ glass: HazeBlurStyle,
onSelected: (String) -> Unit,
) {
// A LazyColumn of hand-chunked rows rather than LazyVerticalGrid: AllowSheetExpansionWhenScrollable
@@ -185,9 +238,18 @@ private fun EmojiGrid(
}
val showBar = query.isBlank() && sections.size > 1
- val hazeState = rememberHazeState()
val scope = rememberCoroutineScope()
+ // Sized in dp rather than sp: the grid is a fixed 7 columns, so a larger font scale would only
+ // crowd the cells, and one shared style keeps each cell from resolving its own.
+ val density = LocalDensity.current
+ val emojiStyle = remember(density) { TextStyle(fontSize = with(density) { EMOJI_SIZE.toSp() }) }
+
+ // The emoji whose tone choice is open, if any.
+ var toneTarget by remember { mutableStateOf(null) }
+ val showTones = remember { { emoji: String -> toneTarget = emoji } }
+ val hideTones = remember { { toneTarget = null } }
+
// A category chosen from the bar whose section the grid is still scrolling to — the indicator
// holds on it so it doesn't pass through every category the grid scrolls over on the way
// (mirrors iOS's `pendingCategory`).
@@ -221,18 +283,23 @@ private fun EmojiGrid(
modifier = Modifier
.fillMaxSize()
.onSizeChanged { gridHeightPx = it.height }
+ .fadeUnderSearchField(CodeTheme.colors.surface)
.hazeSource(hazeState),
contentPadding = PaddingValues(
- top = 4.dp,
+ top = SEARCH_FIELD_INSET,
bottom = if (showBar) CATEGORY_BAR_HEIGHT + 24.dp else 16.dp,
),
) {
- items(rows, key = { row ->
- when (row) {
- is GridRow.Header -> "header-${row.sectionId}"
- is GridRow.Emojis -> "row-${row.sectionId}-${row.entries.first().emoji}"
- }
- }) { row ->
+ items(
+ rows,
+ key = { row ->
+ when (row) {
+ is GridRow.Header -> "header-${row.sectionId}"
+ is GridRow.Emojis -> "row-${row.sectionId}-${row.entries.first().emoji}"
+ }
+ },
+ contentType = { row -> if (row is GridRow.Header) HEADER_CONTENT else ROW_CONTENT },
+ ) { row ->
when (row) {
is GridRow.Header -> {
Text(
@@ -254,7 +321,15 @@ private fun EmojiGrid(
.padding(bottom = 12.dp),
) {
for (entry in row.entries) {
- EmojiCell(entry = entry, onSelected = onSelected)
+ EmojiCell(
+ entry = entry,
+ tones = tones[entry.emoji],
+ showingTones = toneTarget == entry.emoji,
+ style = emojiStyle,
+ onSelected = onSelected,
+ onShowTones = showTones,
+ onHideTones = hideTones,
+ )
}
repeat(COLUMNS - row.entries.size) {
Box(modifier = Modifier.weight(1f))
@@ -270,6 +345,7 @@ private fun EmojiGrid(
sections = sections,
selected = selectedCategory,
hazeState = hazeState,
+ glass = glass,
onSelect = { id ->
pendingCategory = id
val index = sectionStartIndex[id] ?: return@CategoryBar
@@ -289,24 +365,132 @@ private fun EmojiGrid(
}
}
+/**
+ * One grid cell. `combinedClickable` with no interaction source is a plain modifier node that
+ * creates its source only once pressed; the composed `unboundedClickable` it replaces remembered a
+ * source and a ripple in each of the ~50 cells on screen, on every row the fling brought in.
+ */
@Composable
-private fun RowScope.EmojiCell(entry: EmojiCatalogEntry, onSelected: (String) -> Unit) {
+private fun RowScope.EmojiCell(
+ entry: EmojiCatalogEntry,
+ tones: List?,
+ showingTones: Boolean,
+ style: TextStyle,
+ onSelected: (String) -> Unit,
+ onShowTones: (String) -> Unit,
+ onHideTones: () -> Unit,
+) {
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = 41.dp)
- .unboundedClickable { onSelected(entry.emoji) }
+ .combinedClickable(
+ interactionSource = null,
+ indication = CellRipple,
+ onLongClickLabel = if (tones != null) stringResource(R.string.action_chooseSkinTone) else null,
+ onLongClick = if (tones != null) {
+ { onShowTones(entry.emoji) }
+ } else {
+ null
+ },
+ onClick = { onSelected(entry.emoji) },
+ )
.semantics { contentDescription = entry.name },
contentAlignment = Alignment.Center,
) {
- Text(text = entry.emoji, fontSize = 34.sp)
+ BasicText(text = entry.emoji, style = style)
+ if (showingTones && tones != null) {
+ TonePicker(
+ tones = tones,
+ style = style,
+ onSelected = { tone ->
+ onHideTones()
+ onSelected(tone)
+ },
+ onDismiss = onHideTones,
+ )
+ }
}
}
+private val CellRipple = ripple(bounded = false, radius = 24.dp)
+
+/**
+ * The skin tones a long-press offers, in a popup above the pressed cell (below it when there's no
+ * room above): the base, then each tone. Five tones fit one row with the base; the 25 of a
+ * two-person emoji go in rows of five under it.
+ */
+@Composable
+private fun TonePicker(
+ tones: List,
+ style: TextStyle,
+ onSelected: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val density = LocalDensity.current
+ val provider = remember(density) {
+ TonePickerPosition(
+ gap = with(density) { 8.dp.roundToPx() },
+ margin = with(density) { 8.dp.roundToPx() },
+ )
+ }
+ val rows = remember(tones) {
+ if (tones.size <= TONE_ROW + 1) listOf(tones) else listOf(tones.take(1)) + tones.drop(1).chunked(TONE_ROW)
+ }
+ Popup(
+ popupPositionProvider = provider,
+ onDismissRequest = onDismiss,
+ properties = PopupProperties(focusable = true),
+ ) {
+ Column(
+ modifier = Modifier
+ .clip(RoundedCornerShape(22.dp))
+ .background(ToneSurface)
+ .padding(4.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ for (row in rows) {
+ Row {
+ for (tone in row) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .clickable { onSelected(tone) },
+ contentAlignment = Alignment.Center,
+ ) {
+ BasicText(text = tone, style = style)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Centred over the anchor, [gap] above it (below when it won't fit), never within [margin] of the window's edges. */
+private class TonePickerPosition(private val gap: Int, private val margin: Int) : PopupPositionProvider {
+ override fun calculatePosition(
+ anchorBounds: IntRect,
+ windowSize: IntSize,
+ layoutDirection: LayoutDirection,
+ popupContentSize: IntSize,
+ ): IntOffset {
+ val x = (anchorBounds.center.x - popupContentSize.width / 2)
+ .coerceIn(margin, (windowSize.width - popupContentSize.width - margin).coerceAtLeast(margin))
+ val above = anchorBounds.top - gap - popupContentSize.height
+ val y = if (above >= margin) above else anchorBounds.bottom + gap
+ return IntOffset(x, y)
+ }
+}
+
+/** The quick reaction strip's surface, so the two long-press popups match. */
+private val ToneSurface = Color(0xFF303030)
+private const val TONE_ROW = 5
+
/**
* The floating jump bar to each category, a glass capsule over the grid (node 9768:1624), frosted
- * with a Haze blur of the grid scrolling beneath it — Android's analogue of iOS's `.glassEffect`
- * (see [com.flipcash.app.core.ui.NavigationBar] for the same pattern). The selected category sits on
+ * with [rememberLiquidGlass] like the search field. The selected category sits on
* its own capsule indicator, which springs between slots. As on iOS, the indicator can be pressed and
* dragged across the bar; [onSelect] fires only once it's let go over a category, so a plain tap is
* the same gesture with no travel.
@@ -316,15 +500,10 @@ private fun CategoryBar(
sections: List,
selected: String?,
hazeState: HazeState,
+ glass: HazeBlurStyle,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier,
) {
- val backdrop = CodeTheme.colors.background
- val liquidGlass = HazeBlurStyle {
- blurRadius(32.dp)
- backgroundColor(backdrop)
- colorEffects(listOf(HazeColorEffect.tint(backdrop.copy(alpha = 0.72f))))
- }
val haptics = LocalHapticFeedback.current
val density = LocalDensity.current
val selectedIndex = sections.indexOfFirst { it.id == selected }.coerceAtLeast(0)
@@ -338,7 +517,7 @@ private fun CategoryBar(
.padding(horizontal = 30.dp)
.height(CATEGORY_BAR_HEIGHT)
.clip(CircleShape)
- .hazeBlur(HazeInput.Sources(hazeState), liquidGlass),
+ .hazeBlur(HazeInput.Sources(hazeState), glass),
) {
BoxWithConstraints(
modifier = Modifier
@@ -437,12 +616,62 @@ private val CATEGORY_BAR_HEIGHT = 45.dp
private val CATEGORY_BAR_BOTTOM_GAP = 4.dp
private val INDICATOR_HEIGHT = 38.dp
+private val SEARCH_FIELD_TOP = 16.dp
+private val SEARCH_FIELD_HEIGHT = 44.dp
+
+/** Where the grid's first row starts: under the floating search field, with a 12dp gap. */
+private val SEARCH_FIELD_INSET = SEARCH_FIELD_TOP + SEARCH_FIELD_HEIGHT + 12.dp
+
+/** How far the grid takes to fade in. It ends where the first row rests, so nothing is dimmed at rest. */
+private val FADE_LENGTH = 32.dp
+
+private val EMOJI_SIZE = 34.dp
+
+private const val HEADER_CONTENT = "header"
+private const val ROW_CONTENT = "row"
+
+/**
+ * Fades the grid out towards the top, so an emoji scrolling up dissolves as it passes under the
+ * search field instead of being cut by the capsule's bottom edge — Android's stand-in for iOS's soft
+ * scroll edge. Clear at [SEARCH_FIELD_INSET], where the first row rests, [surface] at full strength
+ * [FADE_LENGTH] above it and everything above that, eased with [ChatTopEdge]'s ramp.
+ *
+ * A scrim in the sheet's own colour rather than a `DstIn` alpha mask: the sheet is an opaque
+ * [surface], so the two look the same, but the mask needs an offscreen layer the size of the whole
+ * grid, re-rendered on every scrolled frame, where the scrim draws one strip.
+ *
+ * Applied outside [hazeSource], so the field's blur still samples the emoji at full strength.
+ */
+private fun Modifier.fadeUnderSearchField(surface: Color): Modifier = drawWithCache {
+ val end = SEARCH_FIELD_INSET.toPx()
+ val start = end - FADE_LENGTH.toPx()
+ val stops = Array(ChatTopEdge.RampSamples.size) { index ->
+ val t = ChatTopEdge.RampSamples[index]
+ ((start + t * (end - start)) / end) to surface.copy(alpha = 1f - ChatTopEdge.eased(t))
+ }
+ val scrim = Brush.verticalGradient(colorStops = stops, startY = 0f, endY = end)
+ onDrawWithContent {
+ drawContent()
+ drawRect(brush = scrim, size = Size(size.width, end))
+ }
+}
+
+/**
+ * The search capsule, frosted with the same glass as the category bar. The dark fill over the blur
+ * is the field's look at rest, when nothing is under it yet.
+ */
@Composable
-private fun SearchField(state: TextFieldState, modifier: Modifier = Modifier) {
+private fun SearchField(
+ state: TextFieldState,
+ hazeState: HazeState,
+ glass: HazeBlurStyle,
+ modifier: Modifier = Modifier,
+) {
Row(
modifier = modifier
- .height(44.dp)
+ .height(SEARCH_FIELD_HEIGHT)
.clip(CircleShape)
+ .hazeBlur(HazeInput.Sources(hazeState), glass)
.background(Color.Black.copy(alpha = 0.26f))
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt
index 9eeac8d49d..2fc110629d 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt
@@ -93,10 +93,10 @@ internal object ChatTopEdge {
* strength one pixel, already dropping the next — and those corners are what the eye picks out
* as the edges of a band, however long the ramp between them is. This leaves and arrives flat.
*/
- private fun eased(t: Float): Float = t * t * (3f - 2f * t)
+ fun eased(t: Float): Float = t * t * (3f - 2f * t)
/** Fractions along a ramp at which to sample [eased], enough for the steps not to show. */
- private val RampSamples = floatArrayOf(0f, 0.15f, 0.3f, 0.45f, 0.6f, 0.75f, 0.9f, 1f)
+ val RampSamples = floatArrayOf(0f, 0.15f, 0.3f, 0.45f, 0.6f, 0.75f, 0.9f, 1f)
/**
* Draws the fade behind the bar: opaque to [containerColor] behind the status bar, then a
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerReducerTest.kt
index afc3f1e603..15b18bddae 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerReducerTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerReducerTest.kt
@@ -23,9 +23,9 @@ class EmojiPickerReducerTest {
@Test
fun `loaded sections replace the ones showing and keep the search field`() {
- val first = reduce(EmojiPickerViewModel.State(), EmojiPickerViewModel.Event.SectionsLoaded(sections("all")))
+ val first = reduce(EmojiPickerViewModel.State(), EmojiPickerViewModel.Event.SectionsLoaded(sections("all"), tones = emptyMap()))
- val second = reduce(first, EmojiPickerViewModel.Event.SectionsLoaded(sections("search")))
+ val second = reduce(first, EmojiPickerViewModel.Event.SectionsLoaded(sections("search"), tones = emptyMap()))
assertEquals(sections("search"), second.sections)
assertEquals(true, second.loaded)
diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalog.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalog.kt
index 79d32ec758..83782feb83 100644
--- a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalog.kt
+++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiCatalog.kt
@@ -28,11 +28,49 @@ data class EmojiCatalog(
entries.groupBy { it.category }
}
+ /**
+ * Each base emoji that has skin-tone variants, mapped to those variants in catalog order.
+ *
+ * The catalog lists variants inline (2,030 of its 3,944 entries) and says only that an entry is
+ * one, not which emoji it varies. Most are the base with tone modifiers added, so stripping the
+ * modifiers (and the U+FE0F presentation selector some bases carry and their variants drop)
+ * finds the base. The rest are two-person sequences whose base is a different code point, like
+ * 🫱🏻🫲🏼 for 🤝, and those name their base before the colon ("handshake: light skin tone, …").
+ * The name is only the fallback: "person: medium skin tone, beard" would otherwise group under
+ * 🧑 rather than 🧔.
+ */
+ val skinToneVariants: Map> by lazy {
+ val bases = entries.filterNot { it.skinTone }
+ val byCodePoints = bases.associateBy { it.emoji.withoutToneOrPresentation() }
+ val byName = bases.associateBy { it.name }
+ val variants = LinkedHashMap>()
+ for (entry in entries) {
+ if (!entry.skinTone) continue
+ val base = byCodePoints[entry.emoji.withoutToneOrPresentation()]
+ ?: byName[entry.name.substringBefore(':')]
+ ?: continue
+ variants.getOrPut(base.emoji) { mutableListOf() } += entry
+ }
+ variants
+ }
+
/** The first category's entries, the app-layer strip's fill source (decision 1). */
val firstCategoryEntries: List
get() = categories.firstOrNull()?.let { byCategory[it] }.orEmpty()
}
+private fun String.withoutToneOrPresentation(): String = buildString {
+ var i = 0
+ while (i < this@withoutToneOrPresentation.length) {
+ val codePoint = this@withoutToneOrPresentation.codePointAt(i)
+ if (codePoint !in SKIN_TONE_MODIFIERS && codePoint != VARIATION_SELECTOR_16) appendCodePoint(codePoint)
+ i += Character.charCount(codePoint)
+ }
+}
+
+private val SKIN_TONE_MODIFIERS = 0x1F3FB..0x1F3FF
+private const val VARIATION_SELECTOR_16 = 0xFE0F
+
@Serializable
private data class EmojiCatalogFile(
val categories: List,
diff --git a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModel.kt b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModel.kt
index f59387ba38..79f2000d73 100644
--- a/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModel.kt
+++ b/libs/emojis/src/main/kotlin/com/getcode/libs/emojis/reactions/EmojiPickerModel.kt
@@ -36,6 +36,10 @@ object EmojiPickerModel {
* [undrawable] is filtered out of every section, including the Frequently Used row — a recent
* pick this OS build can no longer render is worth dropping silently rather than showing a tofu
* box for.
+ *
+ * Category and search sections show only base emoji; a skin-tone variant is reached by
+ * long-pressing its base (see [toneOptions]). The Frequently Used row is the exception: a tone
+ * the user picked is the one they reach for, so it stays as picked.
*/
fun sections(
catalog: EmojiCatalog,
@@ -44,11 +48,12 @@ object EmojiPickerModel {
query: String,
): List {
val drawable = catalog.entries.filter { it.emoji !in undrawable }
+ val bases = drawable.filterNot { it.skinTone }
val trimmedQuery = query.trim()
if (trimmedQuery.isNotEmpty()) {
val needle = trimmedQuery.lowercase()
- val matches = drawable.filter { entry ->
+ val matches = bases.filter { entry ->
entry.name.lowercase().contains(needle) ||
entry.keywords.any { it.lowercase().contains(needle) }
}
@@ -68,10 +73,28 @@ object EmojiPickerModel {
}
}
for (category in catalog.categories) {
- val entries = drawable.filter { it.category == category }
+ val entries = bases.filter { it.category == category }
if (entries.isEmpty()) continue
sections += Section(id = category, title = category, entries = entries)
}
return sections
}
+
+ /**
+ * What a long-press on an emoji offers: the base first, then each skin-tone variant this device
+ * can draw, in catalog order. Keyed by the base and by every variant, so a tone in the
+ * Frequently Used row opens the same choice as its base. An emoji with no drawable variant has
+ * no entry.
+ */
+ fun toneOptions(catalog: EmojiCatalog, undrawable: Set): Map> {
+ val options = HashMap>()
+ for ((base, variants) in catalog.skinToneVariants) {
+ if (base in undrawable) continue
+ val tones = variants.map { it.emoji }.filterNot { it in undrawable }
+ if (tones.isEmpty()) continue
+ val choice = listOf(base) + tones
+ for (emoji in choice) options[emoji] = choice
+ }
+ return options
+ }
}
diff --git a/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/SkinToneVariantsTest.kt b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/SkinToneVariantsTest.kt
new file mode 100644
index 0000000000..805b16d001
--- /dev/null
+++ b/libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/SkinToneVariantsTest.kt
@@ -0,0 +1,101 @@
+package com.getcode.libs.emojis.reactions
+
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class SkinToneVariantsTest {
+
+ private fun entry(emoji: String, name: String, skinTone: Boolean = false, category: String = "People") =
+ EmojiCatalogEntry(emoji = emoji, name = name, category = category, version = "1.0", skinTone = skinTone)
+
+ private val thumbsUp = entry("👍", "thumbs up")
+ private val thumbsUpLight = entry("👍🏻", "thumbs up: light skin tone", skinTone = true)
+ private val thumbsUpDark = entry("👍🏿", "thumbs up: dark skin tone", skinTone = true)
+
+ // The base carries U+FE0F; its variants replace it with the modifier.
+ private val detective = entry("🕵️♂️", "man detective")
+ private val detectiveMedium = entry("🕵🏽♂️", "man detective: medium skin tone", skinTone = true)
+
+ // A different code point from its variants, so only the name finds it.
+ private val handshake = entry("🤝", "handshake")
+ private val handshakeMixed = entry("🫱🏻🫲🏿", "handshake: light skin tone, dark skin tone", skinTone = true)
+
+ // "person" is a base too, but this variant's code points name the bearded person.
+ private val person = entry("🧑", "person")
+ private val bearded = entry("🧔", "person: beard")
+ private val beardedMedium = entry("🧔🏽", "person: medium skin tone, beard", skinTone = true)
+
+ private val grin = entry("😀", "grinning face")
+
+ private val catalog = EmojiCatalog(
+ categories = listOf("People"),
+ entries = listOf(
+ grin, thumbsUp, thumbsUpLight, thumbsUpDark, detective, detectiveMedium,
+ handshake, handshakeMixed, person, bearded, beardedMedium,
+ ),
+ )
+
+ @Test
+ fun `variants group under their base by code points, then by name`() {
+ assertEquals(
+ mapOf(
+ thumbsUp.emoji to listOf(thumbsUpLight, thumbsUpDark),
+ detective.emoji to listOf(detectiveMedium),
+ handshake.emoji to listOf(handshakeMixed),
+ bearded.emoji to listOf(beardedMedium),
+ ),
+ catalog.skinToneVariants,
+ )
+ }
+
+ @Test
+ fun `category and search sections show only base emoji`() {
+ val categories = EmojiPickerModel.sections(catalog, undrawable = emptySet(), recents = emptyList(), query = "")
+ assertEquals(listOf(grin, thumbsUp, detective, handshake, person, bearded), categories.single().entries)
+
+ val search = EmojiPickerModel.sections(catalog, undrawable = emptySet(), recents = emptyList(), query = "thumbs")
+ assertEquals(listOf(thumbsUp), search.single().entries)
+ }
+
+ @Test
+ fun `a recent tone stays as picked in frequently used`() {
+ val sections = EmojiPickerModel.sections(
+ catalog = catalog,
+ undrawable = emptySet(),
+ recents = listOf(thumbsUpDark.emoji),
+ query = "",
+ )
+
+ assertEquals(listOf(thumbsUpDark), sections.first { it.id == EmojiPickerModel.FREQUENTLY_USED_ID }.entries)
+ }
+
+ @Test
+ fun `tone options lead with the base, skip undrawable tones, and are keyed by every choice`() {
+ val options = EmojiPickerModel.toneOptions(catalog, undrawable = setOf(thumbsUpLight.emoji))
+
+ val expected = listOf(thumbsUp.emoji, thumbsUpDark.emoji)
+ assertEquals(expected, options[thumbsUp.emoji])
+ assertEquals(expected, options[thumbsUpDark.emoji])
+ assertNull(options[thumbsUpLight.emoji])
+ assertNull(options[grin.emoji])
+ }
+
+ @Test
+ fun `an emoji whose every tone is undrawable has no options`() {
+ val options = EmojiPickerModel.toneOptions(catalog, undrawable = setOf(detectiveMedium.emoji))
+
+ assertNull(options[detective.emoji])
+ }
+
+ @Test
+ fun `every variant in the bundled catalog finds a base`() = runTest {
+ val bundled = EmojiCatalogLoader().load()
+
+ val variants = bundled.entries.count { it.skinTone }
+ assertEquals(variants, bundled.skinToneVariants.values.sumOf { it.size })
+ assertTrue(bundled.skinToneVariants.values.all { it.size == 5 || it.size == 25 })
+ }
+}