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:
2026-03-05 11:17:46 +03:00
Unverified
parent a5a81ffd3c
commit 77aa3fce14
7 changed files with 335 additions and 160 deletions
+3 -1
View File
@@ -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,132 +68,244 @@ 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) {
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
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
animationProgress.floatValue = 0f
}
}
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)
} 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
// 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
)
) {
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
) {
Column {
// Reply button (always shown)
ContextMenuItem(
icon = Icons.AutoMirrored.Filled.Reply,
text = "Reply",
onClick = {
onReply(state.message)
onDismiss()
}
)
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) }
}
}
// Edit button (only for own messages)
if (isAuthor) {
ContextMenuItem(
icon = Icons.Default.Edit,
text = "Edit",
onClick = {
onEdit(state.message)
onDismiss()
}
)
}
val density = LocalDensity.current
val paddingPx = with(density) { 16.dp.toPx().toInt() }
val rightEdge = screenWidthPx - paddingPx
val bottomEdge = screenHeightPx - paddingPx
// Delete button (only for own messages)
if (isAuthor) {
ContextMenuItem(
icon = Icons.Default.Delete,
text = "Delete",
onClick = {
onDelete(state.message)
onDismiss()
},
isError = true
)
}
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 (sizeF.y > 0f) {
((state.position.y - adjustedOffset.y) / sizeF.y).coerceIn(0f, 1f)
} else 0f
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
if (measuredSize != IntSize.Zero) {
Popup(
onDismissRequest = onDismiss,
alignment = Alignment.TopStart,
offset = adjustedOffset,
properties = PopupProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false
)
) {
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)