android: unify chat action sheets and resolve gesture conflicts
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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" },
|
||||
) {
|
||||
|
||||
@@ -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. Безопасность
|
||||
|
||||
Reference in New Issue
Block a user