mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
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:
Generated
+3
-1
@@ -1,6 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="shared">
|
||||
<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>
|
||||
</component>
|
||||
@@ -20,6 +20,7 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
|
||||
HapticFeedbackEvent.ProfileOpened.ordinal -> HapticFeedbackConstants.CLOCK_TICK
|
||||
HapticFeedbackEvent.ProfileClosed.ordinal -> HapticFeedbackConstants.CLOCK_TICK
|
||||
HapticFeedbackEvent.MessageSent.ordinal -> HapticFeedbackConstants.CONFIRM
|
||||
HapticFeedbackEvent.ContextMenuOpened.ordinal -> HapticFeedbackConstants.CONFIRM
|
||||
else -> HapticFeedbackConstants.CLOCK_TICK
|
||||
}
|
||||
view.performHapticFeedback(constant)
|
||||
|
||||
@@ -3,5 +3,6 @@ package ru.fromchat.ui
|
||||
enum class HapticFeedbackEvent {
|
||||
ProfileOpened,
|
||||
ProfileClosed,
|
||||
MessageSent
|
||||
MessageSent,
|
||||
ContextMenuOpened
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -50,13 +49,13 @@ 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.geometry.Rect
|
||||
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.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.HazeMaterials
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -82,12 +80,12 @@ import ru.fromchat.api.WebSocketMessage
|
||||
import ru.fromchat.api.WebSocketUpdatesData
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.core.Logger
|
||||
import ru.fromchat.ui.BackHandler
|
||||
import ru.fromchat.ui.HapticFeedbackEvent
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.rememberHapticFeedback
|
||||
import ru.fromchat.ui.scaleOnPress
|
||||
import ru.fromchat.utils.formatLastSeen
|
||||
import kotlin.time.Clock
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
@@ -548,6 +546,7 @@ fun ChatScreen(
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
userScrollEnabled = !contextMenuState.isOpen,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
|
||||
) {
|
||||
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) }
|
||||
@@ -556,34 +555,24 @@ fun ChatScreen(
|
||||
items = panelState.messages,
|
||||
key = { it.id }
|
||||
) { message ->
|
||||
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) }
|
||||
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
|
||||
var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
messagePosition = IntOffset(
|
||||
coordinates.positionInRoot().x.toInt(),
|
||||
coordinates.positionInRoot().y.toInt()
|
||||
)
|
||||
}
|
||||
) {
|
||||
Box(modifier = Modifier.hazeSource(hazeState)) {
|
||||
MessageItem(
|
||||
message = message,
|
||||
isAuthor = message.user_id == currentUserId,
|
||||
isContextMenuOpen = contextMenuState.isOpen,
|
||||
isContextMenuForThisMessage = contextMenuState.isOpen && contextMenuState.message?.id == message.id,
|
||||
onLongPress = {
|
||||
haptic(HapticFeedbackEvent.ContextMenuOpened)
|
||||
contextMenuState = ContextMenuState(
|
||||
isOpen = true,
|
||||
message = message,
|
||||
position = IntOffset(
|
||||
messagePosition.x + tapOffset.x.toInt(),
|
||||
messagePosition.y + tapOffset.y.toInt()
|
||||
)
|
||||
position = tapPositionInRoot
|
||||
)
|
||||
},
|
||||
onTapPosition = { offset ->
|
||||
tapOffset = offset
|
||||
tapPositionInRoot = IntOffset(offset.x.toInt(), offset.y.toInt())
|
||||
},
|
||||
onImageClick = { msg, idx -> expandedImage = msg to idx },
|
||||
onImageBounds = { key, rect ->
|
||||
@@ -601,29 +590,37 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
MessageContextMenu(
|
||||
state = contextMenuState,
|
||||
isAuthor = contextMenuState.message?.user_id == currentUserId,
|
||||
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
|
||||
onReply = { message ->
|
||||
replyTo = message
|
||||
if (editingMessage != null) {
|
||||
editingMessage = null
|
||||
inputText = ""
|
||||
}
|
||||
},
|
||||
onEdit = { message ->
|
||||
editingMessage = message
|
||||
inputText = message.content
|
||||
replyTo = null
|
||||
},
|
||||
onDelete = { message ->
|
||||
scope.launch {
|
||||
panel.handleDeleteMessage(message.id)
|
||||
}
|
||||
},
|
||||
)
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val density = LocalDensity.current
|
||||
val screenWidthPx = with(density) { maxWidth.toPx().toInt() }
|
||||
val screenHeightPx = with(density) { maxHeight.toPx().toInt() }
|
||||
|
||||
MessageContextMenu(
|
||||
state = contextMenuState,
|
||||
isAuthor = contextMenuState.message?.user_id == currentUserId,
|
||||
hazeState = hazeState,
|
||||
screenWidthPx = screenWidthPx,
|
||||
screenHeightPx = screenHeightPx,
|
||||
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
|
||||
onReply = { message ->
|
||||
replyTo = message
|
||||
if (editingMessage != null) {
|
||||
editingMessage = null
|
||||
inputText = ""
|
||||
}
|
||||
},
|
||||
onEdit = { message ->
|
||||
editingMessage = message
|
||||
inputText = message.content
|
||||
replyTo = null
|
||||
},
|
||||
onDelete = { message ->
|
||||
scope.launch {
|
||||
panel.handleDeleteMessage(message.id)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
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
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.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
|
||||
@@ -29,18 +32,25 @@ 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.draw.clip
|
||||
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.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
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.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import ru.fromchat.api.Message
|
||||
import ru.fromchat.ui.scaleOnPress
|
||||
|
||||
data class ContextMenuState(
|
||||
val isOpen: Boolean = false,
|
||||
@@ -48,6 +58,7 @@ data class ContextMenuState(
|
||||
val position: IntOffset = IntOffset(0, 0)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
@Composable
|
||||
fun MessageContextMenu(
|
||||
@@ -57,6 +68,9 @@ fun MessageContextMenu(
|
||||
onReply: (Message) -> Unit,
|
||||
onEdit: (Message) -> Unit,
|
||||
onDelete: (Message) -> Unit,
|
||||
hazeState: HazeState,
|
||||
screenWidthPx: Int,
|
||||
screenHeightPx: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shouldShowPopup by remember(state.message) {
|
||||
@@ -65,30 +79,21 @@ fun MessageContextMenu(
|
||||
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
|
||||
if (!state.isOpen) {
|
||||
animate(
|
||||
initialValue = 1f,
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(150)
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
) { 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) {
|
||||
shouldShowPopup = true
|
||||
@@ -97,92 +102,210 @@ fun MessageContextMenu(
|
||||
}
|
||||
|
||||
if (shouldShowPopup && state.message != null) {
|
||||
var popupSize by remember { mutableStateOf(Offset(0f, 0f)) }
|
||||
var popupPosition by remember { mutableStateOf(Offset(0f, 0f)) }
|
||||
var measuredSize by remember(state.message) { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
// 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)
|
||||
// Off-screen measurement pass using a lightweight, non-animated version
|
||||
SubcomposeLayout(Modifier.size(0.dp)) { _ ->
|
||||
val looseConstraints = Constraints(
|
||||
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
|
||||
val transformOriginY = if (popupSize.y > 0f) {
|
||||
val clickYInPopup = state.position.y - popupPosition.y.toInt()
|
||||
(clickYInPopup / popupSize.y).coerceIn(0f, 1f)
|
||||
val transformOriginY = if (sizeF.y > 0f) {
|
||||
((state.position.y - adjustedOffset.y) / sizeF.y).coerceIn(0f, 1f)
|
||||
} 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
|
||||
|
||||
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
|
||||
if (measuredSize != IntSize.Zero) {
|
||||
Popup(
|
||||
onDismissRequest = onDismiss,
|
||||
alignment = Alignment.TopStart,
|
||||
offset = adjustedOffset,
|
||||
properties = PopupProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
clippingEnabled = false
|
||||
)
|
||||
) {
|
||||
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.Default.Edit,
|
||||
text = "Edit",
|
||||
onClick = {
|
||||
onEdit(state.message)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Delete button (only for own messages)
|
||||
if (isAuthor) {
|
||||
ContextMenuItem(
|
||||
icon = Icons.Default.Delete,
|
||||
text = "Delete",
|
||||
onClick = {
|
||||
onDelete(state.message)
|
||||
onDismiss()
|
||||
},
|
||||
isError = true
|
||||
)
|
||||
}
|
||||
}
|
||||
ContextMenuContent(
|
||||
message = state.message,
|
||||
isAuthor = isAuthor,
|
||||
onReply = {
|
||||
onReply(it)
|
||||
onDismiss()
|
||||
},
|
||||
onEdit = {
|
||||
onEdit(it)
|
||||
onDismiss()
|
||||
},
|
||||
onDelete = {
|
||||
onDelete(it)
|
||||
onDismiss()
|
||||
},
|
||||
hazeState = hazeState,
|
||||
modifier = modifier,
|
||||
animated = true,
|
||||
scale = scale,
|
||||
alpha = alpha,
|
||||
transformOriginX = transformOriginX,
|
||||
transformOriginY = transformOriginY
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
) {
|
||||
ContextMenuItem(
|
||||
icon = Icons.AutoMirrored.Filled.Reply,
|
||||
text = "Reply",
|
||||
onClick = {
|
||||
onReply(message)
|
||||
}
|
||||
)
|
||||
if (isAuthor) {
|
||||
ContextMenuItem(
|
||||
icon = Icons.Default.Edit,
|
||||
text = "Edit",
|
||||
onClick = { onEdit(message) }
|
||||
)
|
||||
}
|
||||
if (isAuthor) {
|
||||
ContextMenuItem(
|
||||
icon = Icons.Default.Delete,
|
||||
text = "Delete",
|
||||
onClick = { onDelete(message) },
|
||||
isError = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val itemShape = RoundedCornerShape(12.dp)
|
||||
|
||||
@Composable
|
||||
private fun ContextMenuItem(
|
||||
icon: ImageVector,
|
||||
@@ -201,16 +324,21 @@ private fun ContextMenuItem(
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
.padding(4.dp)
|
||||
.clip(itemShape)
|
||||
.scaleOnPress(
|
||||
scale = 0.96f,
|
||||
onClick = onClick,
|
||||
indication = LocalIndication.current
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
|
||||
@@ -2,6 +2,8 @@ package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -28,14 +30,23 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.animation.core.animateFloatAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -69,7 +80,9 @@ fun MessageItem(
|
||||
showUsername: Boolean = true,
|
||||
currentUserId: Int? = null,
|
||||
expandedImageKey: String? = null,
|
||||
isImageClosing: Boolean = false
|
||||
isImageClosing: Boolean = false,
|
||||
isContextMenuOpen: Boolean = false,
|
||||
isContextMenuForThisMessage: Boolean = false
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
@@ -83,18 +96,45 @@ fun MessageItem(
|
||||
),
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = { offset ->
|
||||
onTapPosition(offset)
|
||||
onLongPress()
|
||||
}
|
||||
)
|
||||
},
|
||||
.onGloballyPositioned { coordinates ->
|
||||
rowPositionInRoot = coordinates.positionInRoot()
|
||||
}
|
||||
.then(
|
||||
if (isContextMenuOpen) Modifier
|
||||
else Modifier.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed = true
|
||||
try {
|
||||
awaitRelease()
|
||||
} finally {
|
||||
isPressed = false
|
||||
}
|
||||
},
|
||||
onLongPress = { offset ->
|
||||
onTapPosition(rowPositionInRoot + offset)
|
||||
onLongPress()
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
@@ -125,6 +165,11 @@ fun MessageItem(
|
||||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
transformOrigin = TransformOrigin.Center
|
||||
)
|
||||
// Max width: 70% of row, min width: content-driven
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.clip(
|
||||
|
||||
@@ -18,6 +18,7 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
|
||||
{ ordinal ->
|
||||
val style: UIImpactFeedbackStyle = when (ordinal) {
|
||||
HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
|
||||
HapticFeedbackEvent.ContextMenuOpened.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy
|
||||
else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight
|
||||
}
|
||||
val generator = UIImpactFeedbackGenerator(style)
|
||||
|
||||
Reference in New Issue
Block a user