Ответы

This commit is contained in:
Халимов Рустам
2026-04-17 21:59:53 +03:00
parent 8c00d1376d
commit bef30c2c86
13 changed files with 227 additions and 33 deletions

View File

@@ -67,12 +67,14 @@ class ChatRepositoryImpl @Inject constructor(
chatId: String,
content: String?,
type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?
attachments: List<chats.data.remote.api.AttachmentRequest>?,
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() ?: ""

View File

@@ -11,7 +11,8 @@ interface ChatRepository {
chatId: String,
content: String?,
type: String = "text",
attachments: List<chats.data.remote.api.AttachmentRequest>? = null
attachments: List<chats.data.remote.api.AttachmentRequest>? = null,
replyToId: String? = null
): Message
suspend fun addReaction(messageId: String, emoji: String)
suspend fun sendTypingStatus(chatId: String)

View File

@@ -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<List<chats.domain.model.Media>?>(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

View File

@@ -41,7 +41,8 @@ data class ChatDetailState(
val pendingAttachments: List<File> = 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)

View File

@@ -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

View File

@@ -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()
}
}
}