android: unify chat action sheets and resolve gesture conflicts
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 16:42:33 +03:00
parent af6d8426ba
commit 45918d65cb
3 changed files with 172 additions and 142 deletions

View File

@@ -437,3 +437,10 @@
- Fixed profile screen usability after avatar upload: - Fixed profile screen usability after avatar upload:
- enabled vertical scrolling with safe insets/navigation padding, - enabled vertical scrolling with safe insets/navigation padding,
- constrained avatar preview to a centered circular area instead of full-screen takeover. - constrained avatar preview to a centered circular area instead of full-screen takeover.
### Step 70 - Chat interaction consistency: gestures + sheets/dialogs
- Reworked single-message actions to open in `ModalBottomSheet` (tap action menu) instead of inline action bars.
- Reworked forward target chooser to `ModalBottomSheet` for consistent overlay behavior across chat actions.
- Added destructive action confirmation via `AlertDialog` before delete actions.
- Reduced gesture conflicts by removing attachment-level long-press handlers that collided with message selection gestures.
- Improved voice hold gesture reliability by handling consumed pointer down events (`requireUnconsumed = false`).

View File

@@ -35,11 +35,16 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -56,12 +61,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -186,6 +189,7 @@ fun ChatRoute(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatScreen( fun ChatScreen(
state: MessageUiState, state: MessageUiState,
@@ -226,6 +230,10 @@ fun ChatScreen(
var viewerImageIndex by remember { mutableStateOf<Int?>(null) } var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) } var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) }
var actionMenuMessage by remember { mutableStateOf<MessageItem?>(null) } var actionMenuMessage by remember { mutableStateOf<MessageItem?>(null) }
var pendingDeleteForAll by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
val actionSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val forwardSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
LaunchedEffect(state.isRecordingVoice) { LaunchedEffect(state.isRecordingVoice) {
if (!state.isRecordingVoice) return@LaunchedEffect if (!state.isRecordingVoice) return@LaunchedEffect
@@ -419,71 +427,75 @@ fun ChatScreen(
if (actionMenuMessage != null && state.actionState.mode != MessageSelectionMode.MULTI) { if (actionMenuMessage != null && state.actionState.mode != MessageSelectionMode.MULTI) {
val selected = actionMenuMessage val selected = actionMenuMessage
if (selected != null) { if (selected != null) {
Column( ModalBottomSheet(
modifier = Modifier onDismissRequest = { actionMenuMessage = null },
.fillMaxWidth() sheetState = actionSheetState,
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.96f)) ) {
.padding(horizontal = 12.dp, vertical = 8.dp), Column(
verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier
) { .fillMaxWidth()
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { .padding(horizontal = 16.dp, vertical = 8.dp),
listOf("👍", "❤️", "🔥", "😂").forEach { emoji -> verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("👍", "❤️", "🔥", "😂").forEach { emoji ->
Button(
onClick = {
onSelectMessage(selected)
onToggleReaction(emoji)
actionMenuMessage = null
},
) { Text(emoji) }
}
}
Button(
onClick = {
onReplySelected(selected)
actionMenuMessage = null
},
modifier = Modifier.fillMaxWidth(),
) { Text("Reply") }
Button(
onClick = {
onEditSelected(selected)
actionMenuMessage = null
},
enabled = state.selectedCanEdit,
modifier = Modifier.fillMaxWidth(),
) { Text("Edit") }
Button( Button(
onClick = { onClick = {
onSelectMessage(selected) onSelectMessage(selected)
onToggleReaction(emoji) onForwardSelected()
actionMenuMessage = null actionMenuMessage = null
}, },
) { Text(emoji) } modifier = Modifier.fillMaxWidth(),
) { Text("Forward") }
Button(
onClick = {
onSelectMessage(selected)
pendingDeleteForAll = false
showDeleteDialog = true
actionMenuMessage = null
},
modifier = Modifier.fillMaxWidth(),
) { Text("Delete") }
Button(
onClick = {
onEnterMultiSelect(selected)
actionMenuMessage = null
},
modifier = Modifier.fillMaxWidth(),
) { Text("Select") }
TextButton(
onClick = {
actionMenuMessage = null
onClearSelection()
},
modifier = Modifier.fillMaxWidth(),
) { Text("Close") }
} }
} }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = {
onReplySelected(selected)
actionMenuMessage = null
},
) { Text("Reply") }
Button(
onClick = {
onEditSelected(selected)
actionMenuMessage = null
},
enabled = state.selectedCanEdit,
) { Text("Edit") }
Button(
onClick = {
onSelectMessage(selected)
onForwardSelected()
actionMenuMessage = null
},
) { Text("Forward") }
Button(
onClick = {
onSelectMessage(selected)
onDeleteSelected(false)
actionMenuMessage = null
},
) { Text("Delete") }
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Button(onClick = { onEnterMultiSelect(selected); actionMenuMessage = null }) {
Text("Select")
}
Button(onClick = {}, enabled = false) {
Text("Pin")
}
Button(onClick = { actionMenuMessage = null; onClearSelection() }) {
Text("Close")
}
}
}
} }
} }
@@ -506,11 +518,24 @@ fun ChatScreen(
) )
if (state.actionState.mode == MessageSelectionMode.MULTI) { if (state.actionState.mode == MessageSelectionMode.MULTI) {
Button(onClick = onForwardSelected) { Text("Forward") } Button(onClick = onForwardSelected) { Text("Forward") }
Button(onClick = { onDeleteSelected(false) }) { Text("Delete") }
} else {
Button(onClick = { onDeleteSelected(false) }) { Text("Delete") }
Button( Button(
onClick = { onDeleteSelected(true) }, onClick = {
pendingDeleteForAll = false
showDeleteDialog = true
},
) { Text("Delete") }
} else {
Button(
onClick = {
pendingDeleteForAll = false
showDeleteDialog = true
},
) { Text("Delete") }
Button(
onClick = {
pendingDeleteForAll = true
showDeleteDialog = true
},
enabled = state.selectedCanDeleteForAll, enabled = state.selectedCanDeleteForAll,
) { Text("Del for all") } ) { Text("Del for all") }
Button( Button(
@@ -538,35 +563,44 @@ fun ChatScreen(
} }
if (state.forwardingMessageIds.isNotEmpty()) { if (state.forwardingMessageIds.isNotEmpty()) {
Column( ModalBottomSheet(
modifier = Modifier onDismissRequest = onForwardDismiss,
.fillMaxWidth() sheetState = forwardSheetState,
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Text( Column(
text = if (state.forwardingMessageIds.size == 1) { modifier = Modifier
"Forward message #${state.forwardingMessageIds.first()}" .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = if (state.forwardingMessageIds.size == 1) {
"Forward message #${state.forwardingMessageIds.first()}"
} else {
"Forward ${state.forwardingMessageIds.size} messages"
},
style = MaterialTheme.typography.labelLarge,
)
if (state.availableForwardTargets.isEmpty()) {
Text("No available chats")
} else { } else {
"Forward ${state.forwardingMessageIds.size} messages" state.availableForwardTargets.forEach { target ->
}, Button(
style = MaterialTheme.typography.labelLarge, onClick = { onForwardTargetSelected(target.chatId) },
) enabled = !state.isForwarding,
if (state.availableForwardTargets.isEmpty()) { modifier = Modifier.fillMaxWidth(),
Text("No available chats") ) {
} else { Text(target.title)
state.availableForwardTargets.forEach { target -> }
Button(
onClick = { onForwardTargetSelected(target.chatId) },
enabled = !state.isForwarding,
) {
Text(target.title)
} }
} }
} TextButton(
Button(onClick = onForwardDismiss, enabled = !state.isForwarding) { onClick = onForwardDismiss,
Text(if (state.isForwarding) "Forwarding..." else "Cancel") enabled = !state.isForwarding,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.isForwarding) "Forwarding..." else "Cancel")
}
} }
} }
} }
@@ -690,6 +724,39 @@ fun ChatScreen(
) )
} }
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete message") },
text = {
Text(
if (pendingDeleteForAll) {
"Delete selected message for everyone?"
} else {
"Delete selected message(s) for you?"
}
)
},
confirmButton = {
TextButton(
onClick = {
onDeleteSelected(pendingDeleteForAll)
showDeleteDialog = false
pendingDeleteForAll = false
},
) { Text("Delete") }
},
dismissButton = {
TextButton(
onClick = {
showDeleteDialog = false
pendingDeleteForAll = false
},
) { Text("Cancel") }
},
)
}
if (viewerImageIndex != null) { if (viewerImageIndex != null) {
val currentIndex = viewerImageIndex ?: 0 val currentIndex = viewerImageIndex ?: 0
val currentUrl = allImageUrls.getOrNull(currentIndex) val currentUrl = allImageUrls.getOrNull(currentIndex)
@@ -801,8 +868,6 @@ private fun MessageBubble(
onClick: () -> Unit, onClick: () -> Unit,
onLongPress: () -> Unit, onLongPress: () -> Unit,
) { ) {
val clipboard = LocalClipboardManager.current
var contextAttachmentUrl by remember { mutableStateOf<String?>(null) }
val isOutgoing = message.isOutgoing val isOutgoing = message.isOutgoing
val bubbleShape = if (isOutgoing) { val bubbleShape = if (isOutgoing) {
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 6.dp) RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 6.dp)
@@ -928,10 +993,7 @@ private fun MessageBubble(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp) .height(180.dp)
.combinedClickable( .clickable { onAttachmentImageClick(single.fileUrl) },
onClick = { onAttachmentImageClick(single.fileUrl) },
onLongClick = { contextAttachmentUrl = single.fileUrl },
),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} else { } else {
@@ -947,10 +1009,7 @@ private fun MessageBubble(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(110.dp) .height(110.dp)
.combinedClickable( .clickable { onAttachmentImageClick(image.fileUrl) },
onClick = { onAttachmentImageClick(image.fileUrl) },
onLongClick = { contextAttachmentUrl = image.fileUrl },
),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }
@@ -969,12 +1028,7 @@ private fun MessageBubble(
val fileType = attachment.fileType.lowercase() val fileType = attachment.fileType.lowercase()
when { when {
fileType.startsWith("video/") -> { fileType.startsWith("video/") -> {
Box( Box {
modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = { contextAttachmentUrl = attachment.fileUrl },
),
) {
VideoAttachmentCard( VideoAttachmentCard(
url = attachment.fileUrl, url = attachment.fileUrl,
fileType = attachment.fileType, fileType = attachment.fileType,
@@ -982,22 +1036,12 @@ private fun MessageBubble(
} }
} }
fileType.startsWith("audio/") -> { fileType.startsWith("audio/") -> {
Box( Box {
modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = { contextAttachmentUrl = attachment.fileUrl },
),
) {
AudioAttachmentPlayer(url = attachment.fileUrl) AudioAttachmentPlayer(url = attachment.fileUrl)
} }
} }
else -> { else -> {
Box( Box {
modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = { contextAttachmentUrl = attachment.fileUrl },
),
) {
FileAttachmentRow( FileAttachmentRow(
fileUrl = attachment.fileUrl, fileUrl = attachment.fileUrl,
fileType = attachment.fileType, fileType = attachment.fileType,
@@ -1010,27 +1054,6 @@ private fun MessageBubble(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
} }
} }
if (!contextAttachmentUrl.isNullOrBlank()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Button(onClick = { onAttachmentImageClick(contextAttachmentUrl!!) }) {
Text("Open")
}
Button(
onClick = {
clipboard.setText(AnnotatedString(contextAttachmentUrl!!))
contextAttachmentUrl = null
},
) {
Text("Copy link")
}
Button(onClick = { contextAttachmentUrl = null }) {
Text("Close")
}
}
}
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
@@ -1241,7 +1264,7 @@ private fun VoiceHoldToRecordButton(
.pointerInput(enabled, isRecording, isLocked) { .pointerInput(enabled, isRecording, isLocked) {
if (!enabled || isLocked) return@pointerInput if (!enabled || isLocked) return@pointerInput
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown() val down = awaitFirstDown(requireUnconsumed = false)
onStart() onStart()
var cancelledBySlide = false var cancelledBySlide = false
var lockedBySlide = false var lockedBySlide = false
@@ -1272,7 +1295,7 @@ private fun VoiceHoldToRecordButton(
}, },
) { ) {
Button( Button(
onClick = onStart, onClick = {},
enabled = enabled, enabled = enabled,
modifier = Modifier.semantics { contentDescription = "Hold to record voice message" }, modifier = Modifier.semantics { contentDescription = "Hold to record voice message" },
) { ) {

View File

@@ -98,8 +98,8 @@
## 13. UI/UX и темы ## 13. UI/UX и темы
- [x] Светлая/темная тема (читаемая) - [x] Светлая/темная тема (читаемая)
- [ ] Адаптивность phone/tablet - [ ] Адаптивность phone/tablet
- [ ] Контекстные меню без конфликтов жестов - [x] Контекстные меню без конфликтов жестов
- [ ] Bottom sheets/dialog behavior consistency - [x] Bottom sheets/dialog behavior consistency
- [x] Accessibility (TalkBack, dynamic type) - [x] Accessibility (TalkBack, dynamic type)
## 14. Безопасность ## 14. Безопасность