Fix context menu

This commit is contained in:
2025-12-16 20:45:24 +03:00
Unverified
parent d2383415e3
commit fe4ea2f91b
4 changed files with 152 additions and 83 deletions
+3
View File
@@ -0,0 +1,3 @@
---
alwaysApply: true
---
@@ -41,9 +41,12 @@ 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.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.positionInRoot
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
@@ -331,16 +334,25 @@ fun ChatScreen(
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom) verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
) { ) {
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
items( items(
items = panelState.messages, items = panelState.messages,
key = { it.id } key = { it.id }
) { message -> ) { message ->
val isAuthor = message.user_id == currentUserId val isAuthor = message.user_id == currentUserId
var tapPosition by remember { mutableStateOf(IntOffset(0, 0)) } var messagePosition 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,
@@ -349,16 +361,20 @@ fun ChatScreen(
contextMenuState = ContextMenuState( contextMenuState = ContextMenuState(
isOpen = true, isOpen = true,
message = message, message = message,
position = tapPosition position = IntOffset(
messagePosition.x + tapOffset.x.toInt(),
messagePosition.y + tapOffset.y.toInt()
)
) )
}, },
onTapPosition = { position -> onTapPosition = { offset ->
tapPosition = position tapOffset = offset
} }
) )
} }
} }
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
} }
} }
@@ -381,7 +397,6 @@ fun ChatScreen(
panel.handleDeleteMessage(message.id) panel.handleDeleteMessage(message.id)
} }
}, },
hazeState = hazeState
) )
} }
} }
@@ -1,16 +1,13 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
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.Row import androidx.compose.foundation.layout.Row
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
@@ -19,25 +16,30 @@ 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.HorizontalDivider
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.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.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
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.positionInRoot
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 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.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import ru.fromchat.api.Message import ru.fromchat.api.Message
data class ContextMenuState( data class ContextMenuState(
@@ -46,7 +48,6 @@ data class ContextMenuState(
val position: IntOffset = IntOffset(0, 0) val position: IntOffset = IntOffset(0, 0)
) )
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun MessageContextMenu( fun MessageContextMenu(
state: ContextMenuState, state: ContextMenuState,
@@ -56,14 +57,61 @@ fun MessageContextMenu(
onEdit: (Message) -> Unit, onEdit: (Message) -> Unit,
onDelete: (Message) -> Unit, onDelete: (Message) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hazeState: HazeState
) { ) {
AnimatedVisibility( var shouldShowPopup by remember(state.message) {
visible = state.isOpen, mutableStateOf(state.isOpen && state.message != null)
enter = fadeIn(tween(200)) + scaleIn(initialScale = 0.8f, animationSpec = tween(250)), }
exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.8f, animationSpec = tween(150)) val animationProgress = remember { mutableFloatStateOf(0f) }
) {
LaunchedEffect(state.isOpen) {
if (state.isOpen) {
// Enter animation
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = tween(200)
) { value, _ ->
animationProgress.floatValue = value
}
} else {
// Exit animation
animate(
initialValue = 1f,
targetValue = 0f,
animationSpec = tween(150)
) { value, _ ->
animationProgress.floatValue = value
}
kotlinx.coroutines.delay(150)
shouldShowPopup = false
}
}
// Show popup when opening
LaunchedEffect(state.isOpen, state.message) {
if (state.isOpen && state.message != null) { if (state.isOpen && state.message != null) {
shouldShowPopup = true
animationProgress.floatValue = 0f
}
}
if (shouldShowPopup && state.message != null) {
var popupSize by remember { mutableStateOf(Offset(0f, 0f)) }
var popupPosition by remember { mutableStateOf(Offset(0f, 0f)) }
// Calculate transform origin based on click position relative to popup
val transformOriginX = if (popupSize.x > 0f) {
val clickXInPopup = state.position.x - popupPosition.x.toInt()
(clickXInPopup / popupSize.x).coerceIn(0f, 1f)
} else 0f
val transformOriginY = if (popupSize.y > 0f) {
val clickYInPopup = state.position.y - popupPosition.y.toInt()
(clickYInPopup / popupSize.y).coerceIn(0f, 1f)
} else 0f
val scale = animationProgress.floatValue * 0.2f + 0.8f
val alpha = animationProgress.floatValue
Popup( Popup(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
alignment = Alignment.TopStart, alignment = Alignment.TopStart,
@@ -76,13 +124,22 @@ fun MessageContextMenu(
Surface( Surface(
modifier = modifier modifier = modifier
.width(160.dp) .width(160.dp)
.shadow(8.dp, RoundedCornerShape(8.dp)) .onGloballyPositioned { coordinates ->
.hazeEffect( popupSize = Offset(
state = hazeState, coordinates.size.width.toFloat(),
style = HazeMaterials.thick() coordinates.size.height.toFloat()
), )
popupPosition = coordinates.positionInRoot()
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
alpha = alpha,
transformOrigin = TransformOrigin(transformOriginX, transformOriginY)
)
.shadow(8.dp, RoundedCornerShape(8.dp)),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
color = Color.Transparent color = MaterialTheme.colorScheme.surfaceContainerHighest
) { ) {
Column { Column {
// Reply button (always shown) // Reply button (always shown)
@@ -90,19 +147,18 @@ fun MessageContextMenu(
icon = Icons.AutoMirrored.Filled.Reply, icon = Icons.AutoMirrored.Filled.Reply,
text = "Reply", text = "Reply",
onClick = { onClick = {
state.message?.let { onReply(it) } onReply(state.message)
onDismiss() onDismiss()
} }
) )
// Edit button (only for own messages) // Edit button (only for own messages)
if (isAuthor) { if (isAuthor) {
HorizontalDivider()
ContextMenuItem( ContextMenuItem(
icon = Icons.Default.Edit, icon = Icons.Default.Edit,
text = "Edit", text = "Edit",
onClick = { onClick = {
state.message?.let { onEdit(it) } onEdit(state.message)
onDismiss() onDismiss()
} }
) )
@@ -110,12 +166,11 @@ fun MessageContextMenu(
// Delete button (only for own messages) // Delete button (only for own messages)
if (isAuthor) { if (isAuthor) {
HorizontalDivider()
ContextMenuItem( ContextMenuItem(
icon = Icons.Default.Delete, icon = Icons.Default.Delete,
text = "Delete", text = "Delete",
onClick = { onClick = {
state.message?.let { onDelete(it) } onDelete(state.message)
onDismiss() onDismiss()
}, },
isError = true isError = true
@@ -125,7 +180,6 @@ fun MessageContextMenu(
} }
} }
} }
}
} }
@Composable @Composable
@@ -149,6 +203,7 @@ private fun ContextMenuItem(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
@@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.Brush
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.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.geometry.Offset
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 androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -45,7 +46,7 @@ fun MessageItem(
message: Message, message: Message,
isAuthor: Boolean, isAuthor: Boolean,
onLongPress: () -> Unit, onLongPress: () -> Unit,
onTapPosition: (IntOffset) -> Unit = {}, onTapPosition: (Offset) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
AnimatedVisibility( AnimatedVisibility(
@@ -67,12 +68,7 @@ fun MessageItem(
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onLongPress = { offset -> onLongPress = { offset ->
onTapPosition( onTapPosition(offset)
IntOffset(
offset.x.toInt(),
offset.y.toInt()
)
)
onLongPress() onLongPress()
} }
) )