Ответы
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -67,12 +67,14 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
chatId: String,
|
chatId: String,
|
||||||
content: String?,
|
content: String?,
|
||||||
type: String,
|
type: String,
|
||||||
attachments: List<chats.data.remote.api.AttachmentRequest>?
|
attachments: List<chats.data.remote.api.AttachmentRequest>?,
|
||||||
|
replyToId: String?
|
||||||
): Message {
|
): Message {
|
||||||
val request = SendMessageRequest(
|
val request = SendMessageRequest(
|
||||||
content = content,
|
content = content,
|
||||||
type = type,
|
type = type,
|
||||||
attachments = attachments
|
attachments = attachments,
|
||||||
|
replyToId = replyToId
|
||||||
)
|
)
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
val userId = tokenManager.getUserId() ?: ""
|
val userId = tokenManager.getUserId() ?: ""
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ interface ChatRepository {
|
|||||||
chatId: String,
|
chatId: String,
|
||||||
content: String?,
|
content: String?,
|
||||||
type: String = "text",
|
type: String = "text",
|
||||||
attachments: List<chats.data.remote.api.AttachmentRequest>? = null
|
attachments: List<chats.data.remote.api.AttachmentRequest>? = null,
|
||||||
|
replyToId: String? = null
|
||||||
): Message
|
): Message
|
||||||
suspend fun addReaction(messageId: String, emoji: String)
|
suspend fun addReaction(messageId: String, emoji: String)
|
||||||
suspend fun sendTypingStatus(chatId: String)
|
suspend fun sendTypingStatus(chatId: String)
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ import androidx.compose.foundation.text.BasicTextField
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.rememberAsyncImagePainter
|
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)
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatDetailScreen(
|
fun ChatDetailScreen(
|
||||||
@@ -98,6 +103,14 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
var selectedMediaList by remember { mutableStateOf<List<chats.domain.model.Media>?>(null) }
|
var selectedMediaList by remember { mutableStateOf<List<chats.domain.model.Media>?>(null) }
|
||||||
var initialMediaIndex by remember { mutableIntStateOf(0) }
|
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 playNextVoiceMessage = { currentId: String, speed: Float ->
|
||||||
val currentIndex = state.messages.indexOfFirst { it.id == currentId }
|
val currentIndex = state.messages.indexOfFirst { it.id == currentId }
|
||||||
@@ -319,30 +332,45 @@ fun ChatDetailScreen(
|
|||||||
}) { item ->
|
}) { item ->
|
||||||
when(item) {
|
when(item) {
|
||||||
is MessageListItem.MessageItem -> {
|
is MessageListItem.MessageItem -> {
|
||||||
MessageBubble(
|
SwipeableMessageItem(
|
||||||
message = item.message,
|
onReply = {
|
||||||
isCurrentUser = item.message.senderId == viewModel.getCurrentUserId(),
|
viewModel.onReply(item.message)
|
||||||
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) { }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
) {
|
||||||
|
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 -> {
|
is MessageListItem.DateHeader -> {
|
||||||
Box(
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -628,11 +701,12 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = state.inputText,
|
value = state.inputText,
|
||||||
onValueChange = { viewModel.onInputTextChanged(it) },
|
onValueChange = { viewModel.onInputTextChanged(it) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
if (it.isFocused) {
|
if (it.isFocused) {
|
||||||
isEmojiPickerVisible = false
|
isEmojiPickerVisible = false
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ data class ChatDetailState(
|
|||||||
val pendingAttachments: List<File> = emptyList(),
|
val pendingAttachments: List<File> = emptyList(),
|
||||||
val isUploading: Boolean = false,
|
val isUploading: Boolean = false,
|
||||||
val isCompressionEnabled: Boolean = true,
|
val isCompressionEnabled: Boolean = true,
|
||||||
val inputText: String = ""
|
val inputText: String = "",
|
||||||
|
val replyingMessage: Message? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@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 = {}) {
|
fun sendMessage(onFail: (String) -> Unit = {}) {
|
||||||
val chatId = currentChatId ?: return
|
val chatId = currentChatId ?: return
|
||||||
val text = _state.value.inputText
|
val text = _state.value.inputText
|
||||||
val pending = _state.value.pendingAttachments
|
val pending = _state.value.pendingAttachments
|
||||||
|
val replyToId = _state.value.replyingMessage?.id
|
||||||
if (text.isBlank() && pending.isEmpty()) return
|
if (text.isBlank() && pending.isEmpty()) return
|
||||||
|
|
||||||
// Clear input immediately to avoid double clicks and ensure UI experience
|
// 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 tempId = "temp_${System.currentTimeMillis()}"
|
||||||
val userId = getCurrentUserId()
|
val userId = getCurrentUserId()
|
||||||
@@ -374,7 +385,8 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
content = if (text.isBlank()) null else text,
|
content = if (text.isBlank()) null else text,
|
||||||
type = if (attachmentRequests != null) "media" else "text",
|
type = if (attachmentRequests != null) "media" else "text",
|
||||||
attachments = attachmentRequests
|
attachments = attachmentRequests,
|
||||||
|
replyToId = replyToId
|
||||||
)
|
)
|
||||||
repository.deleteLocalMessage(tempId)
|
repository.deleteLocalMessage(tempId)
|
||||||
repository.saveMessage(sentMessage)
|
repository.saveMessage(sentMessage)
|
||||||
@@ -400,6 +412,9 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun sendVoiceMessage(file: File) {
|
fun sendVoiceMessage(file: File) {
|
||||||
val chatId = currentChatId ?: return
|
val chatId = currentChatId ?: return
|
||||||
|
val replyToId = _state.value.replyingMessage?.id
|
||||||
|
_state.update { it.copy(replyingMessage = null) }
|
||||||
|
|
||||||
val tempId = "temp_voice_${System.currentTimeMillis()}"
|
val tempId = "temp_voice_${System.currentTimeMillis()}"
|
||||||
val userId = getCurrentUserId()
|
val userId = getCurrentUserId()
|
||||||
|
|
||||||
@@ -438,7 +453,8 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
content = null,
|
content = null,
|
||||||
type = "audio",
|
type = "audio",
|
||||||
attachments = listOf(attachment)
|
attachments = listOf(attachment),
|
||||||
|
replyToId = replyToId
|
||||||
)
|
)
|
||||||
|
|
||||||
repository.deleteLocalMessage(tempId)
|
repository.deleteLocalMessage(tempId)
|
||||||
@@ -560,6 +576,9 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun sendGif(url: String) {
|
fun sendGif(url: String) {
|
||||||
val chatId = currentChatId ?: return
|
val chatId = currentChatId ?: return
|
||||||
|
val replyToId = _state.value.replyingMessage?.id
|
||||||
|
_state.update { it.copy(replyingMessage = null) }
|
||||||
|
|
||||||
val tempId = "temp_gif_${System.currentTimeMillis()}"
|
val tempId = "temp_gif_${System.currentTimeMillis()}"
|
||||||
val userId = getCurrentUserId()
|
val userId = getCurrentUserId()
|
||||||
|
|
||||||
@@ -590,7 +609,7 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
fileName = "gif.gif",
|
fileName = "gif.gif",
|
||||||
fileSize = 0
|
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.deleteLocalMessage(tempId)
|
||||||
repository.saveMessage(sentMessage)
|
repository.saveMessage(sentMessage)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ fun MessageBubble(
|
|||||||
autoPlay: Boolean = false,
|
autoPlay: Boolean = false,
|
||||||
initialPlaybackSpeed: Float = 1.0f,
|
initialPlaybackSpeed: Float = 1.0f,
|
||||||
onVoiceFinished: (Float) -> Unit = {},
|
onVoiceFinished: (Float) -> Unit = {},
|
||||||
|
onReplyClick: (Message) -> Unit = {},
|
||||||
onMediaClick: (chats.domain.model.Media) -> Unit = {}
|
onMediaClick: (chats.domain.model.Media) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -114,7 +115,7 @@ fun MessageBubble(
|
|||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(contentColor.copy(alpha = 0.1f))
|
.background(contentColor.copy(alpha = 0.1f))
|
||||||
.height(IntrinsicSize.Min)
|
.height(IntrinsicSize.Min)
|
||||||
.clickable { /* Scroll to reply */ }
|
.clickable { onReplyClick(reply) }
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user