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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
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.dp
import dev.chrisbanes.haze.HazeProgressive
@@ -331,16 +334,25 @@ fun ChatScreen(
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
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 = panelState.messages,
key = { it.id }
) { message ->
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(
modifier = Modifier.hazeSource(hazeState)
modifier = Modifier
.hazeSource(hazeState)
.onGloballyPositioned { coordinates ->
messagePosition = IntOffset(
coordinates.positionInRoot().x.toInt(),
coordinates.positionInRoot().y.toInt()
)
}
) {
MessageItem(
message = message,
@@ -349,16 +361,20 @@ fun ChatScreen(
contextMenuState = ContextMenuState(
isOpen = true,
message = message,
position = tapPosition
position = IntOffset(
messagePosition.x + tapOffset.x.toInt(),
messagePosition.y + tapOffset.y.toInt()
)
)
},
onTapPosition = { position ->
tapPosition = position
onTapPosition = { offset ->
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)
}
},
hazeState = hazeState
)
}
}
@@ -1,16 +1,13 @@
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.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.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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.Modifier
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.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
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
data class ContextMenuState(
@@ -46,7 +48,6 @@ data class ContextMenuState(
val position: IntOffset = IntOffset(0, 0)
)
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun MessageContextMenu(
state: ContextMenuState,
@@ -56,71 +57,124 @@ fun MessageContextMenu(
onEdit: (Message) -> Unit,
onDelete: (Message) -> Unit,
modifier: Modifier = Modifier,
hazeState: HazeState
) {
AnimatedVisibility(
visible = state.isOpen,
enter = fadeIn(tween(200)) + scaleIn(initialScale = 0.8f, animationSpec = tween(250)),
exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.8f, animationSpec = tween(150))
) {
var shouldShowPopup by remember(state.message) {
mutableStateOf(state.isOpen && state.message != null)
}
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) {
Popup(
onDismissRequest = onDismiss,
alignment = Alignment.TopStart,
offset = state.position,
properties = PopupProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true
)
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(
onDismissRequest = onDismiss,
alignment = Alignment.TopStart,
offset = state.position,
properties = PopupProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
Surface(
modifier = modifier
.width(160.dp)
.onGloballyPositioned { coordinates ->
popupSize = Offset(
coordinates.size.width.toFloat(),
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),
color = MaterialTheme.colorScheme.surfaceContainerHighest
) {
Surface(
modifier = modifier
.width(160.dp)
.shadow(8.dp, RoundedCornerShape(8.dp))
.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
),
shape = RoundedCornerShape(8.dp),
color = Color.Transparent
) {
Column {
// Reply button (always shown)
Column {
// Reply button (always shown)
ContextMenuItem(
icon = Icons.AutoMirrored.Filled.Reply,
text = "Reply",
onClick = {
onReply(state.message)
onDismiss()
}
)
// Edit button (only for own messages)
if (isAuthor) {
ContextMenuItem(
icon = Icons.AutoMirrored.Filled.Reply,
text = "Reply",
icon = Icons.Default.Edit,
text = "Edit",
onClick = {
state.message?.let { onReply(it) }
onEdit(state.message)
onDismiss()
}
)
}
// Edit button (only for own messages)
if (isAuthor) {
HorizontalDivider()
ContextMenuItem(
icon = Icons.Default.Edit,
text = "Edit",
onClick = {
state.message?.let { onEdit(it) }
onDismiss()
}
)
}
// Delete button (only for own messages)
if (isAuthor) {
HorizontalDivider()
ContextMenuItem(
icon = Icons.Default.Delete,
text = "Delete",
onClick = {
state.message?.let { onDelete(it) }
onDismiss()
},
isError = true
)
}
// Delete button (only for own messages)
if (isAuthor) {
ContextMenuItem(
icon = Icons.Default.Delete,
text = "Delete",
onClick = {
onDelete(state.message)
onDismiss()
},
isError = true
)
}
}
}
@@ -149,6 +203,7 @@ private fun ContextMenuItem(
Box(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.CenterStart
@@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -45,7 +46,7 @@ fun MessageItem(
message: Message,
isAuthor: Boolean,
onLongPress: () -> Unit,
onTapPosition: (IntOffset) -> Unit = {},
onTapPosition: (Offset) -> Unit = {},
modifier: Modifier = Modifier
) {
AnimatedVisibility(
@@ -67,12 +68,7 @@ fun MessageItem(
.pointerInput(Unit) {
detectTapGestures(
onLongPress = { offset ->
onTapPosition(
IntOffset(
offset.x.toInt(),
offset.y.toInt()
)
)
onTapPosition(offset)
onLongPress()
}
)