mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Merge pull request #6917 from bxdxnn/feat/skin-tone-picker
Add skin tone picker to the emoji picker
This commit is contained in:
+245
-24
@@ -8,61 +8,206 @@
|
|||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.indication
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.PressInteraction
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.ripple
|
import androidx.compose.material3.ripple
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Fill
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onPlaced
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.layout.positionInParent
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Popup
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.emojibasebindings.Emoji
|
import io.element.android.emojibasebindings.Emoji
|
||||||
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
|
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePadding
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePicker
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSize
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSpacing
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.aSkinList
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.text.toDp
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import kotlinx.collections.immutable.ImmutableSet
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EmojiItem(
|
fun EmojiItem(
|
||||||
item: Emoji,
|
item: Emoji,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
onSelectEmoji: (Emoji) -> Unit,
|
onSelectEmoji: (Emoji) -> Unit,
|
||||||
|
onLongPress: (Emoji) -> Unit,
|
||||||
|
onDismissSkinPicker: () -> Unit,
|
||||||
|
skinPickerEmoji: Emoji?,
|
||||||
|
selectedSkinUnicodes: ImmutableSet<String>,
|
||||||
|
hasSelectedSkin: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
emojiSize: TextUnit = 20.sp,
|
emojiSize: TextUnit = 20.sp,
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isSelected) {
|
val backgroundColor = when {
|
||||||
ElementTheme.colors.bgActionPrimaryRest
|
isSelected -> ElementTheme.colors.bgActionPrimaryRest
|
||||||
} else {
|
hasSelectedSkin -> ElementTheme.colors.bgActionTertiarySelected
|
||||||
Color.Transparent
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
|
val density = LocalDensity.current
|
||||||
|
var itemSize by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
var itemOffsetPercent by remember { mutableFloatStateOf(0f) }
|
||||||
|
var hoveredIndex by remember { mutableIntStateOf(-1) }
|
||||||
|
var dismissed by remember { mutableStateOf(false) }
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val description = a11yReactionAction(
|
val description = a11yReactionAction(
|
||||||
emoji = item.unicode,
|
emoji = item.unicode,
|
||||||
userAlreadyReacted = isSelected,
|
userAlreadyReacted = isSelected,
|
||||||
)
|
)
|
||||||
|
val hasSkinTones = !item.skins.isNullOrEmpty()
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.onPlaced { coordinates ->
|
||||||
|
val position = coordinates.positionInParent()
|
||||||
|
val parentBounds = coordinates.parentLayoutCoordinates?.size ?: return@onPlaced
|
||||||
|
// Calculate the offset of the item inside its parent as a percentage, to be able to set the initial offset
|
||||||
|
// for the selection inside the skin tone picker relative to its size.
|
||||||
|
itemOffsetPercent = if (parentBounds.width > 0) {
|
||||||
|
position.x / parentBounds.width
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
}
|
||||||
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)
|
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)
|
||||||
|
.onSizeChanged { itemSize = it }
|
||||||
.background(backgroundColor, CircleShape)
|
.background(backgroundColor, CircleShape)
|
||||||
.clickable(
|
.indication(interactionSource, ripple())
|
||||||
enabled = true,
|
.pointerInput(item) {
|
||||||
onClick = { onSelectEmoji(item) },
|
// Always detect long press and tap gestures
|
||||||
indication = ripple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp),
|
detectTapGestures(
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
onPress = { pressOffset ->
|
||||||
|
val press = PressInteraction.Press(pressOffset)
|
||||||
|
interactionSource.emit(press)
|
||||||
|
if (tryAwaitRelease()) {
|
||||||
|
interactionSource.emit(PressInteraction.Release(press))
|
||||||
|
if (hasSkinTones) {
|
||||||
|
onSelectEmoji(item)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interactionSource.emit(PressInteraction.Cancel(press))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap = { onSelectEmoji(item) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.then(
|
||||||
|
// Only detect drag after long press for those items which have a skin tone picker
|
||||||
|
if (hasSkinTones) {
|
||||||
|
Modifier.pointerInput(item) {
|
||||||
|
val slotWidthPx = with(density) { SkinToneSlotSize.toPx() }
|
||||||
|
val spacingPx = with(density) { SkinToneSlotSpacing.toPx() }
|
||||||
|
val paddingPx = with(density) { SkinTonePadding.toPx() }
|
||||||
|
var startOffset = Offset.Zero
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDragStart = { position ->
|
||||||
|
startOffset = position
|
||||||
|
dismissed = false
|
||||||
|
onLongPress(item)
|
||||||
|
hoveredIndex = -1
|
||||||
|
},
|
||||||
|
onDrag = { change, _ ->
|
||||||
|
if (!dismissed) {
|
||||||
|
change.consume()
|
||||||
|
// It's a valid drag event if it's within ~2 items height above, so, on top of the current one or the skin tone selector,
|
||||||
|
// or 1 item below, which should be far enough to not be triggered by mistake
|
||||||
|
val isValidDrag = if (change.position.y < startOffset.y) {
|
||||||
|
startOffset.y - change.position.y <= itemSize.height * 2f
|
||||||
|
} else {
|
||||||
|
change.position.y - startOffset.y <= itemSize.height
|
||||||
|
}
|
||||||
|
if (!isValidDrag) {
|
||||||
|
dismissed = true
|
||||||
|
hoveredIndex = -1
|
||||||
|
onDismissSkinPicker()
|
||||||
|
} else {
|
||||||
|
val skinItemsCount = item.skins!!.size
|
||||||
|
// Original + variants
|
||||||
|
val totalSlots = 1 + skinItemsCount
|
||||||
|
// Calculate the whole size of the skin tone picker
|
||||||
|
val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx
|
||||||
|
// Calculate the initial offset inside the picker, given the relative position of the item inside its parent
|
||||||
|
val initialOffset = pickerWidthPx * itemOffsetPercent
|
||||||
|
val xInPicker = initialOffset + change.position.x
|
||||||
|
|
||||||
|
// If it's a valid offset, calculate the hovered index, otherwise, set it to -1 to indicate that no item is hovered
|
||||||
|
val index = if (xInPicker in 0f..pickerWidthPx) {
|
||||||
|
(xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1)
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
hoveredIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
if (!dismissed) {
|
||||||
|
val idx = hoveredIndex
|
||||||
|
val skinCount = item.skins?.size ?: 0
|
||||||
|
if (idx in 0..skinCount) {
|
||||||
|
if (idx == 0) {
|
||||||
|
onSelectEmoji(item)
|
||||||
|
} else {
|
||||||
|
val skin = item.skins?.getOrNull(idx - 1)
|
||||||
|
if (skin != null) {
|
||||||
|
onSelectEmoji(item.copy(unicode = skin.unicode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (idx < 0) {
|
||||||
|
onSelectEmoji(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dismissed = false
|
||||||
|
hoveredIndex = -1
|
||||||
|
onDismissSkinPicker()
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
dismissed = false
|
||||||
|
hoveredIndex = -1
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.clearAndSetSemantics {
|
.clearAndSetSemantics {
|
||||||
contentDescription = description
|
contentDescription = description
|
||||||
@@ -73,6 +218,42 @@ fun EmojiItem(
|
|||||||
text = item.unicode,
|
text = item.unicode,
|
||||||
style = LocalTextStyle.current.copy(fontSize = emojiSize),
|
style = LocalTextStyle.current.copy(fontSize = emojiSize),
|
||||||
)
|
)
|
||||||
|
if (hasSkinTones) {
|
||||||
|
val pickerIndicatorColor = ElementTheme.colors.borderInteractivePrimary
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.size(8.dp),
|
||||||
|
) {
|
||||||
|
val path = Path().apply {
|
||||||
|
moveTo(size.width, size.height)
|
||||||
|
lineTo(size.width, 0f)
|
||||||
|
lineTo(0f, size.height)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(path, color = pickerIndicatorColor, style = Fill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skinPickerEmoji == item) {
|
||||||
|
val pickerHeight = SkinTonePadding * 2 + SkinToneSlotSize
|
||||||
|
val popupOffsetPx = with(density) { -pickerHeight.roundToPx() }
|
||||||
|
Popup(
|
||||||
|
onDismissRequest = { onDismissSkinPicker() },
|
||||||
|
alignment = Alignment.BottomCenter,
|
||||||
|
offset = IntOffset(0, popupOffsetPx),
|
||||||
|
) {
|
||||||
|
SkinTonePicker(
|
||||||
|
emoji = item,
|
||||||
|
onSelect = { selectedEmoji ->
|
||||||
|
onSelectEmoji(selectedEmoji)
|
||||||
|
onDismissSkinPicker()
|
||||||
|
},
|
||||||
|
hoveredIndex = hoveredIndex,
|
||||||
|
selectedUnicodes = selectedSkinUnicodes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,21 +261,61 @@ fun EmojiItem(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun EmojiItemPreview() = ElementPreview {
|
internal fun EmojiItemPreview() = ElementPreview {
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.padding(4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
for (isSelected in listOf(true, false)) {
|
for (isSelected in listOf(false, true)) {
|
||||||
EmojiItem(
|
for (skinList in listOf(persistentListOf(), aSkinList())) {
|
||||||
item = Emoji(
|
val hasSelectedSkinValues = if (skinList.isNotEmpty()) listOf(false, true) else listOf(false)
|
||||||
hexcode = "",
|
for (hasSelectedSkin in hasSelectedSkinValues) {
|
||||||
label = "",
|
EmojiItem(
|
||||||
tags = null,
|
item = Emoji(
|
||||||
shortcodes = persistentListOf(),
|
hexcode = "",
|
||||||
unicode = "👍",
|
label = "",
|
||||||
skins = null
|
tags = null,
|
||||||
),
|
shortcodes = persistentListOf(),
|
||||||
isSelected = isSelected,
|
unicode = "👍",
|
||||||
onSelectEmoji = {},
|
skins = skinList,
|
||||||
)
|
),
|
||||||
|
isSelected = isSelected,
|
||||||
|
onSelectEmoji = {},
|
||||||
|
hasSelectedSkin = hasSelectedSkin,
|
||||||
|
onLongPress = {},
|
||||||
|
onDismissSkinPicker = {},
|
||||||
|
skinPickerEmoji = null,
|
||||||
|
selectedSkinUnicodes = persistentSetOf(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun EmojiItemWithPopupPreview() = ElementPreview {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 100.dp, bottom = 4.dp, start = 200.dp, end = 200.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
val item = Emoji(
|
||||||
|
hexcode = "",
|
||||||
|
label = "",
|
||||||
|
tags = null,
|
||||||
|
shortcodes = persistentListOf(),
|
||||||
|
unicode = "👍",
|
||||||
|
skins = aSkinList(),
|
||||||
|
)
|
||||||
|
EmojiItem(
|
||||||
|
item = item,
|
||||||
|
isSelected = false,
|
||||||
|
onSelectEmoji = {},
|
||||||
|
hasSelectedSkin = false,
|
||||||
|
onLongPress = {},
|
||||||
|
onDismissSkinPicker = {},
|
||||||
|
skinPickerEmoji = item,
|
||||||
|
selectedSkinUnicodes = persistentSetOf("👍🏾"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+23
@@ -25,7 +25,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.SecondaryTabRow
|
import androidx.compose.material3.SecondaryTabRow
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
@@ -54,6 +58,8 @@ fun EmojiPicker(
|
|||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val pagerState = rememberPagerState(pageCount = { state.categories.size })
|
val pagerState = rememberPagerState(pageCount = { state.categories.size })
|
||||||
|
var skinPickerEmoji by remember { mutableStateOf<Emoji?>(null) }
|
||||||
|
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
SearchBar(
|
SearchBar(
|
||||||
modifier = Modifier.padding(bottom = 10.dp),
|
modifier = Modifier.padding(bottom = 10.dp),
|
||||||
@@ -68,6 +74,10 @@ fun EmojiPicker(
|
|||||||
emojis = emojis,
|
emojis = emojis,
|
||||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||||
onSelectEmoji = onSelectEmoji,
|
onSelectEmoji = onSelectEmoji,
|
||||||
|
onLongPress = { skinPickerEmoji = it },
|
||||||
|
skinPickerEmoji = skinPickerEmoji,
|
||||||
|
onDismissSkinPicker = { skinPickerEmoji = null },
|
||||||
|
selectedEmojis = selectedEmojis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +116,10 @@ fun EmojiPicker(
|
|||||||
emojis = emojis,
|
emojis = emojis,
|
||||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||||
onSelectEmoji = onSelectEmoji,
|
onSelectEmoji = onSelectEmoji,
|
||||||
|
onLongPress = { skinPickerEmoji = it },
|
||||||
|
skinPickerEmoji = skinPickerEmoji,
|
||||||
|
onDismissSkinPicker = { skinPickerEmoji = null },
|
||||||
|
selectedEmojis = selectedEmojis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +131,10 @@ private fun EmojiResults(
|
|||||||
emojis: ImmutableList<Emoji>,
|
emojis: ImmutableList<Emoji>,
|
||||||
isEmojiSelected: (Emoji) -> Boolean,
|
isEmojiSelected: (Emoji) -> Boolean,
|
||||||
onSelectEmoji: (Emoji) -> Unit,
|
onSelectEmoji: (Emoji) -> Unit,
|
||||||
|
onLongPress: (Emoji) -> Unit,
|
||||||
|
skinPickerEmoji: Emoji?,
|
||||||
|
onDismissSkinPicker: () -> Unit,
|
||||||
|
selectedEmojis: ImmutableSet<String>,
|
||||||
) {
|
) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -131,7 +149,12 @@ private fun EmojiResults(
|
|||||||
item = item,
|
item = item,
|
||||||
isSelected = isEmojiSelected(item),
|
isSelected = isEmojiSelected(item),
|
||||||
onSelectEmoji = onSelectEmoji,
|
onSelectEmoji = onSelectEmoji,
|
||||||
|
onLongPress = onLongPress,
|
||||||
|
skinPickerEmoji = skinPickerEmoji,
|
||||||
|
onDismissSkinPicker = onDismissSkinPicker,
|
||||||
emojiSize = 32.dp.toSp(),
|
emojiSize = 32.dp.toSp(),
|
||||||
|
selectedSkinUnicodes = selectedEmojis,
|
||||||
|
hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.emojibasebindings.Emoji
|
||||||
|
import io.element.android.emojibasebindings.EmojiSkin
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.text.toSp
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import kotlinx.collections.immutable.ImmutableSet
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
|
|
||||||
|
val SkinToneSlotSize = 48.dp
|
||||||
|
val SkinToneSlotSpacing = 2.dp
|
||||||
|
val SkinTonePadding = 4.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SkinTonePicker(
|
||||||
|
emoji: Emoji,
|
||||||
|
hoveredIndex: Int,
|
||||||
|
selectedUnicodes: ImmutableSet<String>,
|
||||||
|
onSelect: (Emoji) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val skins = emoji.skins.orEmpty()
|
||||||
|
val emojiSize = 32.dp.toSp()
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = ElementTheme.colors.bgCanvasDefault,
|
||||||
|
tonalElevation = 4.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(SkinTonePadding),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(SkinToneSlotSpacing),
|
||||||
|
) {
|
||||||
|
SkinToneSlot(
|
||||||
|
unicode = emoji.unicode,
|
||||||
|
emojiSize = emojiSize,
|
||||||
|
isHovered = hoveredIndex == 0,
|
||||||
|
isSelected = emoji.unicode in selectedUnicodes,
|
||||||
|
onClick = { onSelect(emoji) },
|
||||||
|
)
|
||||||
|
skins.forEachIndexed { index, skin ->
|
||||||
|
SkinToneSlot(
|
||||||
|
unicode = skin.unicode,
|
||||||
|
emojiSize = emojiSize,
|
||||||
|
isHovered = hoveredIndex == index + 1,
|
||||||
|
isSelected = skin.unicode in selectedUnicodes,
|
||||||
|
onClick = { onSelect(emoji.copy(unicode = skin.unicode)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SkinToneSlot(
|
||||||
|
unicode: String,
|
||||||
|
emojiSize: TextUnit,
|
||||||
|
isHovered: Boolean,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val background = when {
|
||||||
|
isSelected && isHovered -> ElementTheme.colors.bgActionPrimaryPressed
|
||||||
|
isSelected -> ElementTheme.colors.bgActionPrimaryRest
|
||||||
|
isHovered -> ElementTheme.colors.bgActionPrimaryHovered
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(SkinToneSlotSize)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(background, CircleShape)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = unicode,
|
||||||
|
style = LocalTextStyle.current.copy(fontSize = emojiSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun SkinTonePickerPreview() = ElementPreview {
|
||||||
|
SkinTonePicker(
|
||||||
|
emoji = Emoji(
|
||||||
|
hexcode = "1F44D",
|
||||||
|
label = "thumbs up",
|
||||||
|
tags = null,
|
||||||
|
shortcodes = persistentListOf("+1", "thumbsup"),
|
||||||
|
unicode = "👍",
|
||||||
|
skins = aSkinList(),
|
||||||
|
),
|
||||||
|
hoveredIndex = -1,
|
||||||
|
selectedUnicodes = persistentSetOf(),
|
||||||
|
onSelect = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun aSkinList() = persistentListOf(
|
||||||
|
EmojiSkin("1F44D-1F3FB", "thumbs up: light skin tone", "👍🏻"),
|
||||||
|
EmojiSkin("1F44D-1F3FC", "thumbs up: medium-light skin tone", "👍🏼"),
|
||||||
|
EmojiSkin("1F44D-1F3FD", "thumbs up: medium skin tone", "👍🏽"),
|
||||||
|
EmojiSkin("1F44D-1F3FE", "thumbs up: medium-dark skin tone", "👍🏾"),
|
||||||
|
EmojiSkin("1F44D-1F3FF", "thumbs up: dark skin tone", "👍🏿"),
|
||||||
|
)
|
||||||
+1
-1
@@ -19,7 +19,7 @@ class KonsistParameterNameTest {
|
|||||||
Konsist.scopeFromProject()
|
Konsist.scopeFromProject()
|
||||||
.functions()
|
.functions()
|
||||||
.withParameter { parameter ->
|
.withParameter { parameter ->
|
||||||
parameter.name.endsWith("Press")
|
parameter.name.endsWith("Press") && !parameter.name.endsWith("LongPress")
|
||||||
}
|
}
|
||||||
.assertEmpty(additionalMessage = "Please rename the parameter, for instance from 'onBackPress' to 'onBackClick'.")
|
.assertEmpty(additionalMessage = "Please rename the parameter, for instance from 'onBackPress' to 'onBackClick'.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class KonsistPreviewTest {
|
|||||||
"BackgroundVerticalGradientDisabledPreview",
|
"BackgroundVerticalGradientDisabledPreview",
|
||||||
"BackgroundVerticalGradientPreview",
|
"BackgroundVerticalGradientPreview",
|
||||||
"ColorAliasesPreview",
|
"ColorAliasesPreview",
|
||||||
|
"EmojiItemWithPopupPreview",
|
||||||
"FocusedEventPreview",
|
"FocusedEventPreview",
|
||||||
"GradientFloatingActionButtonCircleShapePreview",
|
"GradientFloatingActionButtonCircleShapePreview",
|
||||||
"HeaderFooterPageScrollablePreview",
|
"HeaderFooterPageScrollablePreview",
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4746d11c30648f7c517b0880021e5a1dc6ed2819af6c800b081b0d842ac2c384
|
||||||
|
size 5325
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user