From fe4ea2f91b4488f304e75f063b82b3387fd4a29c Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 16 Dec 2025 20:45:24 +0300 Subject: [PATCH] Fix context menu --- .cursor/rules/general.mdc | 3 + .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 31 ++- .../ru/fromchat/ui/chat/MessageContextMenu.kt | 191 +++++++++++------- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 10 +- 4 files changed, 152 insertions(+), 83 deletions(-) create mode 100644 .cursor/rules/general.mdc diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc new file mode 100644 index 0000000..3dca909 --- /dev/null +++ b/.cursor/rules/general.mdc @@ -0,0 +1,3 @@ +--- +alwaysApply: true +--- diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 68e08b7..3f0f2bc 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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 ) } } diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt index 1551a7c..671920f 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 4dbca3b..8b46cee 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -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() } )