From 6ed237bcdb8750a70f3f44955cbe2319b1c1b48c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 17:54:24 -0400 Subject: [PATCH] perf(chat): cut the emoji sheet's frame time and match the iOS search field and tones A fast fling through the sheet ran at a p90 of 29-34ms on a Seeker release build. It now runs at 15-16ms. - Cells click through combinedClickable with no interaction source instead of the composed unboundedClickable, which remembered a source and ripple in every cell on screen. - Rows declare a contentType, and emoji share one dp-sized TextStyle. - The glyph probe over the catalog runs once per sheet on the default dispatcher rather than on main for every settled query. The search field now floats over the grid, frosted with the category bar's Haze blur, and the grid fades out beneath it with ChatTopEdge's ramp. The fade is a surface-colour scrim rather than a DstIn mask: the sheet is opaque, and a mask costs an offscreen layer per frame. The grid and search show base emoji only; long-pressing one with skin tones offers them in a popup. The catalog doesn't link a variant to its base, so skinToneVariants strips the tone modifiers, falling back to the name before the colon for two-person sequences like handshake. --- .../core/src/main/res/values/strings.xml | 1 + .../flipcash/app/messenger/ChatFlowScreen.kt | 1 + .../internal/screens/EmojiPickerViewModel.kt | 65 +++- .../internal/screens/ReactionPickerSheet.kt | 307 +++++++++++++++--- .../screens/components/ChatTopEdge.kt | 4 +- .../screens/EmojiPickerReducerTest.kt | 4 +- .../libs/emojis/reactions/EmojiCatalog.kt | 38 +++ .../libs/emojis/reactions/EmojiPickerModel.kt | 27 +- .../emojis/reactions/SkinToneVariantsTest.kt | 101 ++++++ 9 files changed, 488 insertions(+), 60 deletions(-) create mode 100644 libs/emojis/src/test/kotlin/com/getcode/libs/emojis/reactions/SkinToneVariantsTest.kt 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 }) + } +}