diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index d6a08e0..34e281f 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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`). diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 5b1f1d2..c62f113 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -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(null) } var dismissedPinnedMessageId by remember { mutableStateOf(null) } var actionMenuMessage by remember { mutableStateOf(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(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" }, ) { diff --git a/docs/android-checklist.md b/docs/android-checklist.md index cb59092..636dc1c 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -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. Безопасность