Заготовка

This commit is contained in:
Халимов Рустам
2026-05-07 23:27:37 +03:00
parent 2d2e4b685e
commit 9daa786cfb
33 changed files with 1662 additions and 178 deletions
@@ -2,6 +2,7 @@ package chats.presentation.chat_detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import android.util.Log
import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.toDomain
@@ -20,6 +21,7 @@ import core.utils.copyUriToFile
import core.utils.ImageUtils
import chats.data.remote.api.KlipyGifDto
import chats.domain.model.MessageStatus
data class ChatDetailState(
val messages: List<Message> = emptyList(),
@@ -47,7 +49,8 @@ data class ChatDetailState(
val forwardingMessages: List<Message> = emptyList(),
val availableChatsToForward: List<chats.domain.model.Chat> = emptyList(),
val selectedMessageIds: Set<String> = emptySet(),
val pinnedMessages: List<Message> = emptyList()
val pinnedMessages: List<Message> = emptyList(),
val isOffline: Boolean = false
)
@HiltViewModel
@@ -93,6 +96,8 @@ class ChatDetailViewModel @Inject constructor(
return tokenManager.getUserId() ?: ""
}
private var messagesFlowJob: Job? = null
fun setChatId(chatId: String) {
if (currentChatId == chatId) return
currentChatId = chatId
@@ -115,30 +120,43 @@ class ChatDetailViewModel @Inject constructor(
loadChatInfo(chatId)
observeSignalREvents(chatId)
// Initial sync from network
refreshMessages(chatId)
// Подписываемся на Flow из Room (Single Source of Truth)
observeMessagesFromLocal(chatId)
// Запускаем синхронизацию с сервером
viewModelScope.launch {
repository.syncMessagesForChat(chatId)
}
}
private fun updateMessages(messages: List<Message>) {
val sortedMessages = messages.sortedByDescending { it.sequenceId }
_state.update { it.copy(
messages = sortedMessages,
isLoading = false,
initialScrollIndex = 0 // In reverse layout, 0 is the bottom
) }
/**
* Подписка на локальные сообщения из Room (Single Source of Truth)
*/
private fun observeMessagesFromLocal(chatId: String) {
messagesFlowJob?.cancel()
messagesFlowJob = viewModelScope.launch {
repository.getMessagesFlow(chatId)
.catch { e ->
Log.e("ChatDetailVM", "Error observing messages", e)
_state.update { it.copy(isLoading = false, error = e.message) }
}
.collect { messages ->
val sortedMessages = messages.sortedByDescending { it.sequenceId }
_state.update { currentState ->
currentState.copy(
messages = sortedMessages,
isLoading = false,
initialScrollIndex = if (currentState.initialScrollIndex == null) 0 else currentState.initialScrollIndex
)
}
}
}
}
fun onInitialScrollDone() {
_state.update { it.copy(initialScrollIndex = -1) }
}
fun refreshMessages(chatId: String) {
viewModelScope.launch {
val messages = repository.getMessages(chatId)
updateMessages(messages)
}
}
fun loadMoreMessages() {
val chatId = currentChatId ?: return
if (_state.value.isLoading || _state.value.isLoadingMore) return
@@ -488,45 +506,24 @@ class ChatDetailViewModel @Inject constructor(
return
}
// Clear input immediately to avoid double clicks and ensure UI experience
_state.update { it.copy(inputText = "", replyingMessage = null) }
// Clear input immediately для отзывчивого UI
_state.update { it.copy(inputText = "", replyingMessage = null, pendingAttachments = emptyList()) }
val tempId = "temp_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
// Determine mediaType based on attachments
// Определяем тип медиа
val mediaType = when {
pending.isEmpty() -> chats.domain.model.MediaType.TEXT
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> chats.domain.model.MediaType.IMAGE
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> chats.domain.model.MediaType.VIDEO
else -> chats.domain.model.MediaType.TEXT
}
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = if (text.isBlank()) null else text,
createdAt = java.util.Date().toString(),
mediaType = mediaType,
media = emptyList(),
senderName = "Вы",
senderAvatar = null,
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
pending.isEmpty() -> "text"
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> "image"
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> "video"
pending.any { it.extension.lowercase() in listOf("mp3", "m4a", "wav") } -> "audio"
else -> "text"
}
viewModelScope.launch {
try {
// Upload attachments if any
val attachmentRequests = if (_state.value.pendingAttachments.isNotEmpty()) {
// Upload attachments если есть
val attachmentRequests = if (pending.isNotEmpty()) {
_state.update { it.copy(isUploading = true) }
val requests = _state.value.pendingAttachments.map { file ->
val requests = pending.map { file ->
val url = repository.uploadMedia(file)
chats.data.remote.api.AttachmentRequest(
type = when {
@@ -540,31 +537,46 @@ class ChatDetailViewModel @Inject constructor(
fileSize = file.length()
)
}
_state.update { it.copy(isUploading = false, pendingAttachments = emptyList()) }
_state.update { it.copy(isUploading = false) }
requests
} else {
null
}
val sentMessage = repository.sendMessage(
// Отправляем сообщение - теперь оно создается локально и ставится в очередь
repository.sendMessage(
chatId = chatId,
content = if (text.isBlank()) null else text,
type = if (attachmentRequests != null) "media" else "text",
type = mediaType,
attachments = attachmentRequests,
replyToId = replyToId
)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
// Clear attachments on success
_state.update { it.copy(pendingAttachments = emptyList()) }
// Сообщение автоматически появится в UI через Flow из Room
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
onFail(text)
}
}
}
/**
* Повторная отправка неудачного сообщения
*/
fun retryMessage(messageId: String) {
viewModelScope.launch {
repository.retryFailedMessage(messageId)
}
}
/**
* Удаление локального сообщения (отмена отправки)
*/
fun cancelMessage(messageId: String) {
viewModelScope.launch {
repository.deleteLocalMessage(messageId)
}
}
fun addReaction(messageId: String, emoji: String) {
val chatId = currentChatId ?: return
@@ -581,7 +593,7 @@ class ChatDetailViewModel @Inject constructor(
}
val replyToId = _state.value.replyingMessage?.id
_state.update { it.copy(replyingMessage = null) }
val tempId = "temp_voice_${System.currentTimeMillis()}"
val userId = getCurrentUserId()