Implement the new context menu

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>

Improve menu animation

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>

Make menu use rounded corners for menu items

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>

Improve menu layout

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>

Fix indentation

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-03-05 11:17:46 +03:00
Unverified
parent a5a81ffd3c
commit 77aa3fce14
7 changed files with 335 additions and 160 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
<component name="ArtifactManager"> <component name="ArtifactManager">
<artifact type="jar" name="shared"> <artifact type="jar" name="shared">
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path> <output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
<root id="archive" name="shared.jar" /> <root id="archive" name="shared.jar">
<element id="module-output" name="FromChat.utils.shared.androidMain" />
</root>
</artifact> </artifact>
</component> </component>
@@ -20,6 +20,7 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
HapticFeedbackEvent.ProfileOpened.ordinal -> HapticFeedbackConstants.CLOCK_TICK HapticFeedbackEvent.ProfileOpened.ordinal -> HapticFeedbackConstants.CLOCK_TICK
HapticFeedbackEvent.ProfileClosed.ordinal -> HapticFeedbackConstants.CLOCK_TICK HapticFeedbackEvent.ProfileClosed.ordinal -> HapticFeedbackConstants.CLOCK_TICK
HapticFeedbackEvent.MessageSent.ordinal -> HapticFeedbackConstants.CONFIRM HapticFeedbackEvent.MessageSent.ordinal -> HapticFeedbackConstants.CONFIRM
HapticFeedbackEvent.ContextMenuOpened.ordinal -> HapticFeedbackConstants.CONFIRM
else -> HapticFeedbackConstants.CLOCK_TICK else -> HapticFeedbackConstants.CLOCK_TICK
} }
view.performHapticFeedback(constant) view.performHapticFeedback(constant)
@@ -3,5 +3,6 @@ package ru.fromchat.ui
enum class HapticFeedbackEvent { enum class HapticFeedbackEvent {
ProfileOpened, ProfileOpened,
ProfileClosed, ProfileClosed,
MessageSent MessageSent,
ContextMenuOpened
} }
@@ -1,16 +1,15 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
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.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -50,13 +49,13 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue 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.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeProgressive
@@ -65,7 +64,6 @@ import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import kotlin.time.Clock
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
@@ -82,12 +80,12 @@ import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.ui.BackHandler
import ru.fromchat.ui.HapticFeedbackEvent import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.rememberHapticFeedback import ru.fromchat.ui.rememberHapticFeedback
import ru.fromchat.ui.scaleOnPress import ru.fromchat.ui.scaleOnPress
import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatLastSeen
import kotlin.time.Clock
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
@@ -548,6 +546,7 @@ fun ChatScreen(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
userScrollEnabled = !contextMenuState.isOpen,
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom) verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
) { ) {
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) }
@@ -556,34 +555,24 @@ fun ChatScreen(
items = panelState.messages, items = panelState.messages,
key = { it.id } key = { it.id }
) { message -> ) { message ->
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) } var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
Box( Box(modifier = Modifier.hazeSource(hazeState)) {
modifier = Modifier
.hazeSource(hazeState)
.onGloballyPositioned { coordinates ->
messagePosition = IntOffset(
coordinates.positionInRoot().x.toInt(),
coordinates.positionInRoot().y.toInt()
)
}
) {
MessageItem( MessageItem(
message = message, message = message,
isAuthor = message.user_id == currentUserId, isAuthor = message.user_id == currentUserId,
isContextMenuOpen = contextMenuState.isOpen,
isContextMenuForThisMessage = contextMenuState.isOpen && contextMenuState.message?.id == message.id,
onLongPress = { onLongPress = {
haptic(HapticFeedbackEvent.ContextMenuOpened)
contextMenuState = ContextMenuState( contextMenuState = ContextMenuState(
isOpen = true, isOpen = true,
message = message, message = message,
position = IntOffset( position = tapPositionInRoot
messagePosition.x + tapOffset.x.toInt(),
messagePosition.y + tapOffset.y.toInt()
)
) )
}, },
onTapPosition = { offset -> onTapPosition = { offset ->
tapOffset = offset tapPositionInRoot = IntOffset(offset.x.toInt(), offset.y.toInt())
}, },
onImageClick = { msg, idx -> expandedImage = msg to idx }, onImageClick = { msg, idx -> expandedImage = msg to idx },
onImageBounds = { key, rect -> onImageBounds = { key, rect ->
@@ -601,10 +590,17 @@ fun ChatScreen(
} }
} }
@Suppress("AssignedValueIsNeverRead") BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val density = LocalDensity.current
val screenWidthPx = with(density) { maxWidth.toPx().toInt() }
val screenHeightPx = with(density) { maxHeight.toPx().toInt() }
MessageContextMenu( MessageContextMenu(
state = contextMenuState, state = contextMenuState,
isAuthor = contextMenuState.message?.user_id == currentUserId, isAuthor = contextMenuState.message?.user_id == currentUserId,
hazeState = hazeState,
screenWidthPx = screenWidthPx,
screenHeightPx = screenHeightPx,
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) }, onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
onReply = { message -> onReply = { message ->
replyTo = message replyTo = message
@@ -626,6 +622,7 @@ fun ChatScreen(
) )
} }
} }
}
expandedImage?.let { (msg, idx) -> expandedImage?.let { (msg, idx) ->
val key = "img_${msg.id}_$idx" val key = "img_${msg.id}_$idx"
@@ -1,24 +1,27 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animate import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable import androidx.compose.foundation.LocalIndication
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.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -29,18 +32,25 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.draw.shadow import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.ui.scaleOnPress
data class ContextMenuState( data class ContextMenuState(
val isOpen: Boolean = false, val isOpen: Boolean = false,
@@ -48,6 +58,7 @@ data class ContextMenuState(
val position: IntOffset = IntOffset(0, 0) val position: IntOffset = IntOffset(0, 0)
) )
@OptIn(ExperimentalHazeMaterialsApi::class)
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
@Composable @Composable
fun MessageContextMenu( fun MessageContextMenu(
@@ -57,6 +68,9 @@ fun MessageContextMenu(
onReply: (Message) -> Unit, onReply: (Message) -> Unit,
onEdit: (Message) -> Unit, onEdit: (Message) -> Unit,
onDelete: (Message) -> Unit, onDelete: (Message) -> Unit,
hazeState: HazeState,
screenWidthPx: Int,
screenHeightPx: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var shouldShowPopup by remember(state.message) { var shouldShowPopup by remember(state.message) {
@@ -65,30 +79,21 @@ fun MessageContextMenu(
val animationProgress = remember { mutableFloatStateOf(0f) } val animationProgress = remember { mutableFloatStateOf(0f) }
LaunchedEffect(state.isOpen) { LaunchedEffect(state.isOpen) {
if (state.isOpen) { if (!state.isOpen) {
// Enter animation
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = tween(200)
) { value, _ ->
animationProgress.floatValue = value
}
} else {
// Exit animation
animate( animate(
initialValue = 1f, initialValue = 1f,
targetValue = 0f, targetValue = 0f,
animationSpec = tween(150) animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
)
) { value, _ -> ) { value, _ ->
animationProgress.floatValue = value animationProgress.floatValue = value
} }
kotlinx.coroutines.delay(150)
shouldShowPopup = false shouldShowPopup = false
} }
} }
// Show popup when opening
LaunchedEffect(state.isOpen, state.message) { LaunchedEffect(state.isOpen, state.message) {
if (state.isOpen && state.message != null) { if (state.isOpen && state.message != null) {
shouldShowPopup = true shouldShowPopup = true
@@ -97,91 +102,209 @@ fun MessageContextMenu(
} }
if (shouldShowPopup && state.message != null) { if (shouldShowPopup && state.message != null) {
var popupSize by remember { mutableStateOf(Offset(0f, 0f)) } var measuredSize by remember(state.message) { mutableStateOf(IntSize.Zero) }
var popupPosition by remember { mutableStateOf(Offset(0f, 0f)) }
// Calculate transform origin based on click position relative to popup // Off-screen measurement pass using a lightweight, non-animated version
val transformOriginX = if (popupSize.x > 0f) { SubcomposeLayout(Modifier.size(0.dp)) { _ ->
val clickXInPopup = state.position.x - popupPosition.x.toInt() val looseConstraints = Constraints(
(clickXInPopup / popupSize.x).coerceIn(0f, 1f) minWidth = 0,
minHeight = 0,
maxWidth = screenWidthPx,
maxHeight = screenHeightPx
)
val placeables = subcompose("measure") {
ContextMenuContent(
message = state.message,
isAuthor = isAuthor,
onReply = {},
onEdit = {},
onDelete = {},
modifier = modifier.graphicsLayer(alpha = 0f),
animated = false,
withShadow = false,
)
}.map { it.measure(looseConstraints) }
val p = placeables.firstOrNull()
if (p != null && measuredSize == IntSize.Zero) {
measuredSize = IntSize(p.width, p.height)
}
layout(0, 0) {
placeables.forEach { it.placeRelative(-10000, -10000) }
}
}
val density = LocalDensity.current
val paddingPx = with(density) { 16.dp.toPx().toInt() }
val rightEdge = screenWidthPx - paddingPx
val bottomEdge = screenHeightPx - paddingPx
val adjustedOffset = remember(measuredSize, state.position, rightEdge, bottomEdge, paddingPx) {
if (measuredSize == IntSize.Zero) {
state.position
} else {
var x = state.position.x
var y = state.position.y
if (x + measuredSize.width > rightEdge) x = rightEdge - measuredSize.width
if (y + measuredSize.height > bottomEdge) y = bottomEdge - measuredSize.height
if (x < paddingPx) x = paddingPx
if (y < paddingPx) y = paddingPx
IntOffset(x, y)
}
}
val sizeF = Offset(measuredSize.width.toFloat(), measuredSize.height.toFloat())
val transformOriginX = if (sizeF.x > 0f) {
((state.position.x - adjustedOffset.x) / sizeF.x).coerceIn(0f, 1f)
} else 0f } else 0f
val transformOriginY = if (popupSize.y > 0f) { val transformOriginY = if (sizeF.y > 0f) {
val clickYInPopup = state.position.y - popupPosition.y.toInt() ((state.position.y - adjustedOffset.y) / sizeF.y).coerceIn(0f, 1f)
(clickYInPopup / popupSize.y).coerceIn(0f, 1f)
} else 0f } else 0f
val scale = animationProgress.floatValue * 0.2f + 0.8f LaunchedEffect(measuredSize) {
if (measuredSize != IntSize.Zero) {
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
)
) { value, _ ->
animationProgress.floatValue = value
}
}
}
val scale = 0.5f + 0.5f * animationProgress.floatValue
val alpha = animationProgress.floatValue val alpha = animationProgress.floatValue
if (measuredSize != IntSize.Zero) {
Popup( Popup(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
alignment = Alignment.TopStart, alignment = Alignment.TopStart,
offset = state.position, offset = adjustedOffset,
properties = PopupProperties( properties = PopupProperties(
dismissOnBackPress = true, dismissOnBackPress = true,
dismissOnClickOutside = true dismissOnClickOutside = true,
clippingEnabled = false
) )
) { ) {
Surface( ContextMenuContent(
modifier = modifier message = state.message,
.width(160.dp) isAuthor = isAuthor,
.onGloballyPositioned { coordinates -> onReply = {
popupSize = Offset( onReply(it)
coordinates.size.width.toFloat(), onDismiss()
coordinates.size.height.toFloat() },
) onEdit = {
popupPosition = coordinates.positionInRoot() onEdit(it)
} onDismiss()
.graphicsLayer( },
scaleX = scale, onDelete = {
scaleY = scale, onDelete(it)
onDismiss()
},
hazeState = hazeState,
modifier = modifier,
animated = true,
scale = scale,
alpha = alpha, alpha = alpha,
transformOrigin = TransformOrigin(transformOriginX, transformOriginY) transformOriginX = transformOriginX,
transformOriginY = transformOriginY
) )
.shadow(8.dp, RoundedCornerShape(8.dp)), }
shape = RoundedCornerShape(8.dp), }
color = MaterialTheme.colorScheme.surfaceContainerHighest }
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun ContextMenuContent(
message: Message,
isAuthor: Boolean,
onReply: (Message) -> Unit,
onEdit: (Message) -> Unit,
onDelete: (Message) -> Unit,
modifier: Modifier,
animated: Boolean,
hazeState: HazeState? = null,
withShadow: Boolean = true,
scale: Float = 1f,
alpha: Float = 1f,
transformOriginX: Float = 0f,
transformOriginY: Float = 0f,
) {
val menuShape = RoundedCornerShape(16.dp)
val menuScrollState = rememberScrollState()
val baseModifier = modifier.width(IntrinsicSize.Max)
val clipped = baseModifier.clip(menuShape)
val density = LocalDensity.current
val shadowElevationPx = if (withShadow) {
with(density) { 12.dp.toPx() }
} else {
0f
}
val finalModifier =
if (hazeState != null) {
clipped
.graphicsLayer(
scaleX = if (animated) scale else 1f,
scaleY = if (animated) scale else 1f,
alpha = if (animated) alpha else 1f,
transformOrigin = TransformOrigin(transformOriginX, transformOriginY),
shadowElevation = shadowElevationPx,
shape = menuShape,
clip = true
)
.hazeEffect(state = hazeState, style = CupertinoMaterials.regular()) {
blurRadius = 8.dp
}
} else {
clipped.graphicsLayer(
shadowElevation = shadowElevationPx,
shape = menuShape,
clip = true
)
}
val edgePadding = 8.dp
val itemSpacing = 2.dp
Box(modifier = finalModifier) {
Column(
modifier = Modifier
.padding(horizontal = edgePadding, vertical = edgePadding)
.verticalScroll(menuScrollState),
verticalArrangement = Arrangement.spacedBy(itemSpacing)
) { ) {
Column {
// Reply button (always shown)
ContextMenuItem( ContextMenuItem(
icon = Icons.AutoMirrored.Filled.Reply, icon = Icons.AutoMirrored.Filled.Reply,
text = "Reply", text = "Reply",
onClick = { onClick = {
onReply(state.message) onReply(message)
onDismiss()
} }
) )
// Edit button (only for own messages)
if (isAuthor) { if (isAuthor) {
ContextMenuItem( ContextMenuItem(
icon = Icons.Default.Edit, icon = Icons.Default.Edit,
text = "Edit", text = "Edit",
onClick = { onClick = { onEdit(message) }
onEdit(state.message)
onDismiss()
}
) )
} }
// Delete button (only for own messages)
if (isAuthor) { if (isAuthor) {
ContextMenuItem( ContextMenuItem(
icon = Icons.Default.Delete, icon = Icons.Default.Delete,
text = "Delete", text = "Delete",
onClick = { onClick = { onDelete(message) },
onDelete(state.message)
onDismiss()
},
isError = true isError = true
) )
} }
} }
} }
} }
}
} private val itemShape = RoundedCornerShape(12.dp)
@Composable @Composable
private fun ContextMenuItem( private fun ContextMenuItem(
@@ -201,16 +324,21 @@ private fun ContextMenuItem(
} else { } else {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
} }
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .padding(4.dp)
.padding(horizontal = 16.dp, vertical = 12.dp), .clip(itemShape)
.scaleOnPress(
scale = 0.96f,
onClick = onClick,
indication = LocalIndication.current
)
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
@@ -2,6 +2,8 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -28,14 +30,23 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
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.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -69,7 +80,9 @@ fun MessageItem(
showUsername: Boolean = true, showUsername: Boolean = true,
currentUserId: Int? = null, currentUserId: Int? = null,
expandedImageKey: String? = null, expandedImageKey: String? = null,
isImageClosing: Boolean = false isImageClosing: Boolean = false,
isContextMenuOpen: Boolean = false,
isContextMenuForThisMessage: Boolean = false
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = true, visible = true,
@@ -83,18 +96,45 @@ fun MessageItem(
), ),
modifier = modifier modifier = modifier
) { ) {
var isPressed by remember { mutableStateOf(false) }
var rowPositionInRoot by remember { mutableStateOf(Offset.Zero) }
val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f
val scale by animateFloatAsState(
targetValue = scaleTarget,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow
),
visibilityThreshold = 0.001f,
label = "messageBubbleScale"
)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
.pointerInput(Unit) { .onGloballyPositioned { coordinates ->
rowPositionInRoot = coordinates.positionInRoot()
}
.then(
if (isContextMenuOpen) Modifier
else Modifier.pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
}
},
onLongPress = { offset -> onLongPress = { offset ->
onTapPosition(offset) onTapPosition(rowPositionInRoot + offset)
onLongPress() onLongPress()
} }
) )
}, }
),
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
@@ -125,6 +165,11 @@ fun MessageItem(
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true) message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true)
Box( Box(
modifier = Modifier modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
transformOrigin = TransformOrigin.Center
)
// Max width: 70% of row, min width: content-driven // Max width: 70% of row, min width: content-driven
.widthIn(max = maxBubbleWidth) .widthIn(max = maxBubbleWidth)
.clip( .clip(
@@ -18,6 +18,7 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
{ ordinal -> { ordinal ->
val style: UIImpactFeedbackStyle = when (ordinal) { val style: UIImpactFeedbackStyle = when (ordinal) {
HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
HapticFeedbackEvent.ContextMenuOpened.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy
else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight
} }
val generator = UIImpactFeedbackGenerator(style) val generator = UIImpactFeedbackGenerator(style)