Заготовка
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user