Заготовка

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()
@@ -51,10 +51,51 @@ class ChatListViewModel @Inject constructor(
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
}
loadChats()
// Single Source of Truth - подписываемся на Flow из Room
observeChatsFromLocal()
// Запускаем периодическую синхронизацию
viewModelScope.launch {
repository.schedulePeriodicSync()
}
observeSignalREvents()
}
/**
* Подписка на локальные чаты из Room (Single Source of Truth)
*/
private fun observeChatsFromLocal() {
repository.getChatsFlow()
.onEach { chats ->
_state.update { currentState ->
currentState.copy(
chats = sortChats(chats),
isLoading = false
)
}
}
.catch { e ->
android.util.Log.e("ChatListVM", "Error observing chats", e)
_state.update { it.copy(isLoading = false, error = e.message) }
}
.launchIn(viewModelScope)
}
/**
* Принудительная синхронизация чатов с сервером
*/
fun loadChats() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
repository.syncChats()
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
private fun updatePushToken() {
com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
@@ -70,21 +111,8 @@ class ChatListViewModel @Inject constructor(
}
}
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
fun loadChats() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
val chats = repository.getChats()
_state.update { it.copy(chats = sortChats(chats), isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
private fun sortChats(chats: List<Chat>): List<Chat> {
return chats.sortedWith(compareByDescending<Chat> {
it.name.equals("Избранное", ignoreCase = true) || it.name.equals("Saved Messages", ignoreCase = true)
@@ -121,7 +149,6 @@ class ChatListViewModel @Inject constructor(
.launchIn(viewModelScope)
}
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = getCurrentUserId()
@@ -29,6 +29,7 @@ import androidx.compose.ui.window.Popup
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.model.Media
import chats.domain.model.MessageStatus
import coil.compose.AsyncImage
import core.presentation.components.AppVideoPlayer
import core.presentation.components.AppAudioPlayer
@@ -483,12 +484,44 @@ fun MessageBubble(
}
if (isCurrentUser) {
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = contentColor.copy(alpha = 0.6f)
)
// Отображение статуса отправки сообщения
when (message.status) {
MessageStatus.PENDING -> {
// Часы - ожидает отправки
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = "Pending",
modifier = Modifier.size(12.dp),
tint = contentColor.copy(alpha = 0.6f)
)
}
MessageStatus.SENDING -> {
// Круговой индикатор загрузки
CircularProgressIndicator(
modifier = Modifier.size(10.dp),
strokeWidth = 1.5.dp,
color = contentColor.copy(alpha = 0.6f)
)
}
MessageStatus.FAILED -> {
// Красный крестик - ошибка отправки
Icon(
imageVector = Icons.Default.Error,
contentDescription = "Failed",
modifier = Modifier.size(12.dp),
tint = Color(0xFFFF5252)
)
}
else -> {
// Одна галочка для SENT, две для DELIVERED/READ
Icon(
imageVector = if (message.status == MessageStatus.READ) Icons.Default.DoneAll else Icons.Default.Done,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = if (message.status == MessageStatus.READ) Color(0xFF4CAF50) else contentColor.copy(alpha = 0.6f)
)
}
}
}
}
} // End of bubble Column