From 922d1da6c22ba7fab03017aef2ec9ece4aaef5e9 Mon Sep 17 00:00:00 2001 From: Infi Date: Thu, 19 Oct 2023 01:12:40 +0200 Subject: [PATCH] feat(emoji-picker): in-memory skin tone selector Signed-off-by: Infi --- .../revolt/components/emoji/EmojiPicker.kt | 196 +++++++++++++++++- .../{EmojiMetadata.kt => EmojiImpl.kt} | 51 ++++- app/src/main/res/values/strings.xml | 8 + 3 files changed, 245 insertions(+), 10 deletions(-) rename app/src/main/java/chat/revolt/internals/{EmojiMetadata.kt => EmojiImpl.kt} (74%) diff --git a/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt b/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt index 8474543c..e09a4e88 100644 --- a/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt +++ b/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt @@ -2,53 +2,75 @@ package chat.revolt.components.emoji import android.util.TypedValue import android.view.HapticFeedbackConstants +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.revolt.R +import chat.revolt.activities.RevoltTweenFloat import chat.revolt.api.REVOLT_FILES import chat.revolt.callbacks.Action import chat.revolt.callbacks.ActionChannel import chat.revolt.components.generic.IconPlaceholder import chat.revolt.components.generic.RemoteImage import chat.revolt.internals.Category -import chat.revolt.internals.EmojiMetadata +import chat.revolt.internals.EmojiImpl import chat.revolt.internals.EmojiPickerItem +import chat.revolt.internals.FitzpatrickSkinTone import chat.revolt.internals.UnicodeEmojiSection import kotlinx.coroutines.launch @@ -58,13 +80,18 @@ fun EmojiPicker( onEmojiSelected: (String) -> Unit, ) { val view = LocalView.current - val metadata = remember { EmojiMetadata() } - val pickerList = remember(metadata) { metadata.flatPickerList() } - val servers = remember(metadata) { metadata.serversWithEmotes() } - val categorySpans = remember(pickerList) { metadata.categorySpans(pickerList) } + val focusManager = LocalFocusManager.current + + val emojiImpl = remember { EmojiImpl() } + val pickerList = remember(emojiImpl) { emojiImpl.flatPickerList() } + val servers = remember(emojiImpl) { emojiImpl.serversWithEmotes() } + val categorySpans = remember(pickerList) { emojiImpl.categorySpans(pickerList) } + val gridState = rememberLazyGridState() val categoryRowScrollState = rememberScrollState() + val scope = rememberCoroutineScope() + val spanCount = 9 // https://github.com/googlefonts/emoji-metadata/#readme // The current category is the one that the user is currently looking at. @@ -98,10 +125,154 @@ fun EmojiPicker( categoryRowScrollState.animateScrollTo(offset * px) } + var currentSkinTone by remember { mutableStateOf(FitzpatrickSkinTone.None) } + var showSkinToneMenu by remember { mutableStateOf(false) } + val skinToneMenuAreaWeight by animateFloatAsState( + if (showSkinToneMenu) 1f else .15f, + animationSpec = RevoltTweenFloat, + label = "skinToneMenuAreaWeight" + ) + val skinToneMenuCloseHintIconOpacity by animateFloatAsState( + if (showSkinToneMenu) 1f else 0f, + animationSpec = RevoltTweenFloat, + label = "skinToneMenuCloseHintIconOpacity" + ) + + val skinSample = remember(pickerList) { + pickerList + .filterIsInstance() + .first { it.character == "\uD83E\uDEF0" } + } + + var searchQuery by remember { mutableStateOf("Search not implemented yet") } + val searchFieldOpacity by animateFloatAsState( + if (showSkinToneMenu) 0f else 1f, + animationSpec = RevoltTweenFloat, + label = "searchFieldOpacity" + ) + Column( modifier = Modifier .fillMaxSize() ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(37.dp) + ) { + BasicTextField( + value = searchQuery, + onValueChange = { + searchQuery = it + }, + textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + readOnly = showSkinToneMenu, + singleLine = true, + modifier = Modifier + .fillMaxWidth(.9f) + .alpha(searchFieldOpacity) + .align(Alignment.CenterStart) + ) { innerTextField -> + Box( + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + innerTextField() + } + } + + Row( + modifier = Modifier + .height(37.dp) + .fillMaxWidth(skinToneMenuAreaWeight) + .align(Alignment.CenterEnd), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(Modifier.weight(1f)) + + AnimatedVisibility( + showSkinToneMenu + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FitzpatrickSkinTone.entries.forEach { skinTone -> + Text( + emojiImpl.applyFitzpatrickSkinTone( + skinSample, + skinTone + ), + modifier = Modifier + .padding(4.dp) + .requiredSize(24.dp) + .clip(CircleShape) + .clickable( + onClickLabel = when (skinTone) { + FitzpatrickSkinTone.None -> stringResource(R.string.emoji_picker_skin_tone_none) + FitzpatrickSkinTone.Light -> stringResource(R.string.emoji_picker_skin_tone_fitzpatrick_1_2) + FitzpatrickSkinTone.MediumLight -> stringResource(R.string.emoji_picker_skin_tone_fitzpatrick_3) + FitzpatrickSkinTone.Medium -> stringResource(R.string.emoji_picker_skin_tone_fitzpatrick_4) + FitzpatrickSkinTone.MediumDark -> stringResource(R.string.emoji_picker_skin_tone_fitzpatrick_5) + FitzpatrickSkinTone.Dark -> stringResource(R.string.emoji_picker_skin_tone_fitzpatrick_6) + } + ) { + currentSkinTone = skinTone + showSkinToneMenu = false + focusManager.clearFocus() // this prevents the text field Z-below from gaining focus + } + .aspectRatio(1f), + textAlign = TextAlign.Center, + ) + } + } + } + + Spacer(Modifier.width(4.dp)) + + Box( + modifier = Modifier + .padding(end = 8.dp) + .requiredSize(24.dp) + .clip(CircleShape) + .clickable { + showSkinToneMenu = !showSkinToneMenu + } + .aspectRatio(1f) + ) { + Text( + emojiImpl.applyFitzpatrickSkinTone( + skinSample, + currentSkinTone + ), + modifier = Modifier + .padding(4.dp) + .requiredSize(24.dp) + .clip(CircleShape) + .aspectRatio(1f) + .alpha(1f - skinToneMenuCloseHintIconOpacity), + textAlign = TextAlign.Center + ) + Icon( + imageVector = if (LocalLayoutDirection.current == LayoutDirection.Rtl) { + Icons.Default.KeyboardArrowLeft + } else { + Icons.Default.KeyboardArrowRight + }, + contentDescription = stringResource(R.string.emoji_picker_close_skin_tone_menu), + tint = LocalContentColor.current, + modifier = Modifier + .alpha(skinToneMenuCloseHintIconOpacity) + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + Row( modifier = Modifier .horizontalScroll(categoryRowScrollState) @@ -221,14 +392,25 @@ fun EmojiPicker( .clip(CircleShape) .clickable { view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - onEmojiSelected(item.emoji) + onEmojiSelected( + emojiImpl.applyFitzpatrickSkinTone( + item, + currentSkinTone + ) + ) } .aspectRatio(1f) .weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text(item.emoji, style = LocalTextStyle.current.copy(fontSize = 20.sp)) + Text( + emojiImpl.applyFitzpatrickSkinTone( + item, + currentSkinTone + ), + style = LocalTextStyle.current.copy(fontSize = 20.sp) + ) } } diff --git a/app/src/main/java/chat/revolt/internals/EmojiMetadata.kt b/app/src/main/java/chat/revolt/internals/EmojiImpl.kt similarity index 74% rename from app/src/main/java/chat/revolt/internals/EmojiMetadata.kt rename to app/src/main/java/chat/revolt/internals/EmojiImpl.kt index bf557028..c66c4a00 100644 --- a/app/src/main/java/chat/revolt/internals/EmojiMetadata.kt +++ b/app/src/main/java/chat/revolt/internals/EmojiImpl.kt @@ -24,6 +24,15 @@ data class EmojiGroup( val emoji: List, ) +enum class FitzpatrickSkinTone(val modifierCodepoint: Int?) { + None(null), + Light(0x1F3FB), + MediumLight(0x1F3FC), + Medium(0x1F3FD), + MediumDark(0x1F3FE), + Dark(0x1F3FF), +} + enum class UnicodeEmojiSection(val googleName: String, val nameResource: Int) { Smileys("Smileys and emotions", R.string.emoji_category_smileys), People("People", R.string.emoji_category_people), @@ -43,11 +52,16 @@ sealed class Category { sealed class EmojiPickerItem { data class Section(val category: Category) : EmojiPickerItem() - data class UnicodeEmoji(val emoji: String) : EmojiPickerItem() + data class UnicodeEmoji( + val character: String, + val hasSkinTones: Boolean, + val alternates: List>, + ) : EmojiPickerItem() + data class ServerEmote(val emote: chat.revolt.api.schemas.Emoji) : EmojiPickerItem() } -class EmojiMetadata { +class EmojiImpl { private var metadata: List private fun initMetadata(context: Context): List { @@ -94,7 +108,13 @@ class EmojiMetadata { list.add(EmojiPickerItem.Section(Category.UnicodeEmojiCategory(category))) list.addAll(group.emoji.map { emoji -> EmojiPickerItem.UnicodeEmoji( - emoji.base.joinToString("") { String(Character.toChars(it.toInt())) } + emoji.base.joinToString("") { String(Character.toChars(it.toInt())) }, + emoji.alternates.any { alternate -> + alternate.any { codepoint -> + codepoint in 0x1F3FB..0x1F3FF + } + }, + emoji.alternates ) }) } @@ -145,6 +165,31 @@ class EmojiMetadata { return output } + /** + * All of our unicode emoji are the base variant with no modifiers applied by default. + * This function returns the unicode emoji with the modifier from the specified skin type applied. + */ + fun applyFitzpatrickSkinTone( + item: EmojiPickerItem.UnicodeEmoji, + skinType: FitzpatrickSkinTone + ): String { + if (!item.hasSkinTones || skinType == FitzpatrickSkinTone.None) return item.character + + // HACK: We simply find the modifier version from metadata that + // contains the skin tone modifier codepoint. + val modifier = item.alternates.maxByOrNull { alternate -> + // HACK HACK: We find the alternate with the most frequency of our skin tone modifier. + // This is because some emoji have multiple skin tone modifier and we are taking the + // easy way here by only allowing a single skin tone change. This is not ideal. + // Users are encouraged to use the system emoji keyboard to get the full range of + // skin tone modifiers. + alternate.count { it == skinType.modifierCodepoint?.toLong() } + } + + return modifier?.joinToString("") { String(Character.toChars(it.toInt())) } + ?: item.character + } + init { metadata = initMetadata(RevoltApplication.instance.applicationContext) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f8caef11..0881c3f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -343,6 +343,14 @@ Attach a file Take a photo + Close skin tone menu + No skin tone + Light skin tone + Medium-light skin tone + Medium skin tone + Medium-dark skin tone + Dark skin tone + The settings are in the sidebar You can open the sidebar by swiping from the left edge of the screen. Then long tap your profile picture to open the settings.