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 }) + } +}