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">
|
<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,92 +102,210 @@ 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(
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user