diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index 215eab6..16c344b 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index 62fbae1..85cfe74 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index 9938b73..973789f 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index 914b868..09715ad 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index a6723ad..b1543db 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index e733648..794f912 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index 4db7b06..c4c8f46 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index f087bc9..a612e1e 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -67,12 +67,14 @@ class ChatRepositoryImpl @Inject constructor( chatId: String, content: String?, type: String, - attachments: List? + attachments: List?, + replyToId: String? ): Message { val request = SendMessageRequest( content = content, type = type, - attachments = attachments + attachments = attachments, + replyToId = replyToId ) val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") val userId = tokenManager.getUserId() ?: "" diff --git a/client-mobile/chats/domain/repository/ChatRepository.kt b/client-mobile/chats/domain/repository/ChatRepository.kt index f405efe..8c93e32 100644 --- a/client-mobile/chats/domain/repository/ChatRepository.kt +++ b/client-mobile/chats/domain/repository/ChatRepository.kt @@ -11,7 +11,8 @@ interface ChatRepository { chatId: String, content: String?, type: String = "text", - attachments: List? = null + attachments: List? = null, + replyToId: String? = null ): Message suspend fun addReaction(messageId: String, emoji: String) suspend fun sendTypingStatus(chatId: String) diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index a8896fd..981950e 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -56,6 +56,11 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.ui.unit.sp import coil.compose.rememberAsyncImagePainter +import chats.presentation.components.SwipeableMessageItem +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester + @OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ChatDetailScreen( @@ -98,6 +103,14 @@ fun ChatDetailScreen( var selectedMediaList by remember { mutableStateOf?>(null) } var initialMediaIndex by remember { mutableIntStateOf(0) } + val focusRequester = remember { FocusRequester() } + + // Автоматический фокус при ответе + LaunchedEffect(state.replyingMessage) { + if (state.replyingMessage != null) { + focusRequester.requestFocus() + } + } val playNextVoiceMessage = { currentId: String, speed: Float -> val currentIndex = state.messages.indexOfFirst { it.id == currentId } @@ -319,30 +332,45 @@ fun ChatDetailScreen( }) { item -> when(item) { is MessageListItem.MessageItem -> { - MessageBubble( - message = item.message, - isCurrentUser = item.message.senderId == viewModel.getCurrentUserId(), - autoPlay = item.message.id == autoPlayingMessageId, - initialPlaybackSpeed = if (item.message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f, - onVoiceFinished = { speed -> playNextVoiceMessage(item.message.id, speed) }, - onReactionClick = { emoji -> viewModel.addReaction(item.message.id, emoji) }, - onMediaClick = { clickedMedia -> - val isMedia = clickedMedia.type.startsWith("image") || - clickedMedia.type.startsWith("video") || - clickedMedia.type.contains("gif") - - if (isMedia) { - val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url } - selectedMediaList = allChatMedia.map { it.first } - initialMediaIndex = if (initialIndex != -1) initialIndex else 0 - } else { - try { - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url)) - context.startActivity(intent) - } catch (e: Exception) { } - } + SwipeableMessageItem( + onReply = { + viewModel.onReply(item.message) } - ) + ) { + MessageBubble( + message = item.message, + isCurrentUser = item.message.senderId == viewModel.getCurrentUserId(), + autoPlay = item.message.id == autoPlayingMessageId, + initialPlaybackSpeed = if (item.message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f, + onVoiceFinished = { speed -> playNextVoiceMessage(item.message.id, speed) }, + onReactionClick = { emoji -> viewModel.addReaction(item.message.id, emoji) }, + onReplyClick = { reply -> + // TODO: Scroll to reply + val index = listItems.indexOfFirst { + it is MessageListItem.MessageItem && it.message.id == reply.id + } + if (index != -1) { + scope.launch { listState.animateScrollToItem(index) } + } + }, + onMediaClick = { clickedMedia -> + val isMedia = clickedMedia.type.startsWith("image") || + clickedMedia.type.startsWith("video") || + clickedMedia.type.contains("gif") + + if (isMedia) { + val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url } + selectedMediaList = allChatMedia.map { it.first } + initialMediaIndex = if (initialIndex != -1) initialIndex else 0 + } else { + try { + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url)) + context.startActivity(intent) + } catch (e: Exception) { } + } + } + ) + } } is MessageListItem.DateHeader -> { Box( @@ -586,6 +614,51 @@ fun ChatDetailScreen( } } } + // Reply Preview Bar + state.replyingMessage?.let { replyMsg -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(2.dp) + .background(MaterialTheme.colorScheme.primary) + ) + Icon( + imageVector = Icons.Default.Reply, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp).size(20.dp) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = "Ответ " + (if (replyMsg.senderId == viewModel.getCurrentUserId()) "самом себе" else replyMsg.senderName), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + Text( + text = replyMsg.content ?: if (replyMsg.media.isNotEmpty()) "Медиа" else "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + IconButton(onClick = { viewModel.cancelReply() }) { + Icon(Icons.Default.Close, contentDescription = "Cancel", modifier = Modifier.size(20.dp), tint = Color.Gray) + } + } + } Row( modifier = Modifier @@ -628,11 +701,12 @@ fun ChatDetailScreen( ) } - BasicTextField( + BasicTextField( value = state.inputText, onValueChange = { viewModel.onInputTextChanged(it) }, modifier = Modifier .weight(1f) + .focusRequester(focusRequester) .onFocusChanged { if (it.isFocused) { isEmojiPickerVisible = false diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index d938bc0..1e086ca 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -41,7 +41,8 @@ data class ChatDetailState( val pendingAttachments: List = emptyList(), val isUploading: Boolean = false, val isCompressionEnabled: Boolean = true, - val inputText: String = "" + val inputText: String = "", + val replyingMessage: Message? = null ) @HiltViewModel @@ -306,14 +307,24 @@ class ChatDetailViewModel @Inject constructor( } } + + fun onReply(message: Message) { + _state.update { it.copy(replyingMessage = message) } + } + + fun cancelReply() { + _state.update { it.copy(replyingMessage = null) } + } + fun sendMessage(onFail: (String) -> Unit = {}) { val chatId = currentChatId ?: return val text = _state.value.inputText val pending = _state.value.pendingAttachments + val replyToId = _state.value.replyingMessage?.id if (text.isBlank() && pending.isEmpty()) return // Clear input immediately to avoid double clicks and ensure UI experience - _state.update { it.copy(inputText = "") } + _state.update { it.copy(inputText = "", replyingMessage = null) } val tempId = "temp_${System.currentTimeMillis()}" val userId = getCurrentUserId() @@ -374,7 +385,8 @@ class ChatDetailViewModel @Inject constructor( chatId = chatId, content = if (text.isBlank()) null else text, type = if (attachmentRequests != null) "media" else "text", - attachments = attachmentRequests + attachments = attachmentRequests, + replyToId = replyToId ) repository.deleteLocalMessage(tempId) repository.saveMessage(sentMessage) @@ -400,6 +412,9 @@ class ChatDetailViewModel @Inject constructor( fun sendVoiceMessage(file: File) { val chatId = currentChatId ?: return + val replyToId = _state.value.replyingMessage?.id + _state.update { it.copy(replyingMessage = null) } + val tempId = "temp_voice_${System.currentTimeMillis()}" val userId = getCurrentUserId() @@ -438,7 +453,8 @@ class ChatDetailViewModel @Inject constructor( chatId = chatId, content = null, type = "audio", - attachments = listOf(attachment) + attachments = listOf(attachment), + replyToId = replyToId ) repository.deleteLocalMessage(tempId) @@ -560,6 +576,9 @@ class ChatDetailViewModel @Inject constructor( fun sendGif(url: String) { val chatId = currentChatId ?: return + val replyToId = _state.value.replyingMessage?.id + _state.update { it.copy(replyingMessage = null) } + val tempId = "temp_gif_${System.currentTimeMillis()}" val userId = getCurrentUserId() @@ -590,7 +609,7 @@ class ChatDetailViewModel @Inject constructor( fileName = "gif.gif", fileSize = 0 ) - val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment)) + val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment), replyToId) repository.deleteLocalMessage(tempId) repository.saveMessage(sentMessage) diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt index 88516a8..7b30a20 100644 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ b/client-mobile/chats/presentation/components/MessageBubble.kt @@ -50,6 +50,7 @@ fun MessageBubble( autoPlay: Boolean = false, initialPlaybackSpeed: Float = 1.0f, onVoiceFinished: (Float) -> Unit = {}, + onReplyClick: (Message) -> Unit = {}, onMediaClick: (chats.domain.model.Media) -> Unit = {} ) { val context = LocalContext.current @@ -114,7 +115,7 @@ fun MessageBubble( .clip(RoundedCornerShape(4.dp)) .background(contentColor.copy(alpha = 0.1f)) .height(IntrinsicSize.Min) - .clickable { /* Scroll to reply */ } + .clickable { onReplyClick(reply) } ) { Box( modifier = Modifier diff --git a/client-mobile/chats/presentation/components/SwipeableMessageItem.kt b/client-mobile/chats/presentation/components/SwipeableMessageItem.kt new file mode 100644 index 0000000..c05a147 --- /dev/null +++ b/client-mobile/chats/presentation/components/SwipeableMessageItem.kt @@ -0,0 +1,97 @@ +package chats.presentation.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Reply +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun SwipeableMessageItem( + onReply: () -> Unit, + content: @Composable () -> Unit +) { + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val offsetX = remember { Animatable(0f) } + val replyThreshold = with(density) { 60.dp.toPx() } + val maxDrag = with(density) { 90.dp.toPx() } + + var isTriggered by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxWidth() + .draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + scope.launch { + // We only allow swiping to the left (negative delta) + val newOffset = (offsetX.value + delta).coerceIn(-maxDrag, 0f) + offsetX.snapTo(newOffset) + + if (newOffset <= -replyThreshold && !isTriggered) { + isTriggered = true + // Trigger haptic feedback if available? + } else if (newOffset > -replyThreshold) { + isTriggered = false + } + } + }, + onDragStopped = { + if (offsetX.value <= -replyThreshold) { + onReply() + } + scope.launch { + offsetX.animateTo(0f) + isTriggered = false + } + } + ) + ) { + // Reply Icon (Underneath) + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp) + .size(36.dp) + .alpha(((-offsetX.value) / replyThreshold).coerceIn(0f, 1f)) + .clip(CircleShape) + .background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Reply, + contentDescription = "Reply", + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + + // Message Content + Box( + modifier = Modifier + .offset { IntOffset(offsetX.value.roundToInt(), 0) } + .fillMaxWidth() + ) { + content() + } + } +}