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

View File

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