Правки

This commit is contained in:
Халимов Рустам
2026-05-13 13:48:34 +03:00
parent 00ed4b5959
commit df4feeeee5
12 changed files with 263 additions and 82 deletions

View File

@@ -149,31 +149,44 @@ fun ChatDetailScreen(
viewModel.setChatId(chatId)
}
// Безопасная группировка сообщений с обработкой ошибок
val listItems = remember(state.messages) {
val items = mutableListOf<MessageListItem>()
if (state.messages.isEmpty()) return@remember items
// Группируем от старых к новым (для normal layout)
val reversedMessages = state.messages.reversed()
var i = 0
while (i < reversedMessages.size) {
val isoDate = reversedMessages[i].createdAt
val localDate = viewModel.getLocalDateString(isoDate)
val dayMessages = mutableListOf<chats.domain.model.Message>()
while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) {
dayMessages.add(reversedMessages[i])
i++
}
// Добавляем заголовок даты
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
// Добавляем сообщения
dayMessages.forEach { msg ->
items.add(MessageListItem.MessageItem(msg))
try {
// Удаляем дубликаты по ID сообщения
val uniqueMessages = state.messages.distinctBy { it.id }
if (uniqueMessages.isEmpty()) return@remember items
// Группируем от старых к новым (для normal layout)
val reversedMessages = uniqueMessages.reversed()
var i = 0
while (i < reversedMessages.size) {
val isoDate = reversedMessages[i].createdAt
val localDate = viewModel.getLocalDateString(isoDate)
val dayMessages = mutableListOf<chats.domain.model.Message>()
while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) {
dayMessages.add(reversedMessages[i])
i++
}
// Добавляем заголовок даты
val safeDateHeader = try {
viewModel.formatDateHeader(isoDate)
} catch (e: Exception) {
isoDate.take(10)
}
items.add(MessageListItem.DateHeader(safeDateHeader))
// Добавляем сообщения
dayMessages.forEach { msg ->
items.add(MessageListItem.MessageItem(msg))
}
}
} catch (e: Exception) {
android.util.Log.e("ChatDetailScreen", "Error building listItems", e)
}
items
}
@@ -255,28 +268,38 @@ fun ChatDetailScreen(
}
// Авто-скролл к новым сообщениям - только если мы уже внизу
val lastMessageId = state.messages.lastOrNull()?.id
// Берём firstOrNull, т.к. messages отсортированы по sequenceId убыванию (новые в начале)
val lastMessageId = state.messages.firstOrNull()?.id
LaunchedEffect(lastMessageId) {
if (lastMessageId != null && listItems.isNotEmpty()) {
val isAtBottom = listState.layoutInfo.run {
val visibleItems = visibleItemsInfo
val totalCount = totalItemsCount
if (totalCount == 0 || visibleItems.isEmpty()) return@run true
visibleItems.last().index >= totalCount - 2
}
if (isAtBottom) {
listState.animateScrollToItem(listItems.size - 1)
// Даём время LazyColumn отрисовать новое сообщение и обновить layout
delay(200)
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val totalCount = layoutInfo.totalItemsCount
if (totalCount > 0 && visibleItems.isNotEmpty()) {
val lastVisibleIndex = visibleItems.last().index
val atBottom = lastVisibleIndex >= totalCount - 2
android.util.Log.d("ChatDetailScreen", "Auto-scroll check: lastVisible=$lastVisibleIndex, total=$totalCount, atBottom=$atBottom, msgId=$lastMessageId")
if (atBottom) {
listState.animateScrollToItem(listItems.size - 1)
}
}
}
}
// Автоматическая прокрутка при инициализации
// Автоматическая прокрутка при инициализации - только один раз
var initialScrollDone by remember { mutableStateOf(false) }
LaunchedEffect(listItems.size) {
if (listItems.isNotEmpty()) {
listState.scrollToItem(listItems.size - 1)
viewModel.onInitialScrollDone()
viewModel.markAsRead()
if (!initialScrollDone && listItems.isNotEmpty() && listItems.size > 1) {
try {
listState.scrollToItem(listItems.size - 1)
initialScrollDone = true
viewModel.onInitialScrollDone()
viewModel.markAsRead()
} catch (e: Exception) {
android.util.Log.e("ChatDetailScreen", "Error in auto-scroll", e)
}
}
}
@@ -300,6 +323,20 @@ fun ChatDetailScreen(
state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() }
}
// Авто-прочитка при открытии чата - если мы внизу
LaunchedEffect(Unit) {
delay(500)
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val totalCount = layoutInfo.totalItemsCount
if (totalCount > 0 && visibleItems.isNotEmpty()) {
val lastVisibleIndex = visibleItems.last().index
if (lastVisibleIndex >= totalCount - 2) {
viewModel.markAsRead()
}
}
}
// Авто-прочитка при нахождении внизу списка
LaunchedEffect(listState.isScrollInProgress) {
if (!listState.isScrollInProgress) {

View File

@@ -123,7 +123,16 @@ class ChatDetailViewModel @Inject constructor(
// Подписываемся на Flow из Room (Single Source of Truth)
observeMessagesFromLocal(chatId)
// Запускаем синхронизацию с сервером
// Немедленно грузим историю с сервера, чтобы не ждать WorkManager
viewModelScope.launch {
try {
repository.getMessages(chatId, limit = 50)
} catch (e: Exception) {
android.util.Log.e("ChatDetailVM", "Initial messages load failed", e)
}
}
// Дополнительно ставим фоновую синхронизацию через WorkManager
viewModelScope.launch {
repository.syncMessagesForChat(chatId)
}
@@ -351,6 +360,7 @@ class ChatDetailViewModel @Inject constructor(
currentState.copy(messages = updatedMessages)
}
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
repository.resetUnreadCount(chatId)
signalrNotificationObserver.refresh()
} catch (e: Exception) {
// Ignore
@@ -641,7 +651,8 @@ class ChatDetailViewModel @Inject constructor(
fun formatDateHeader(dateString: String): String {
return try {
// Парсим ISO 8601 (например 2024-04-14T20:56:00Z)
if (dateString.isBlank()) return "Unknown"
// Парсим ISO 8001 (например 2024-04-14T20:56:00Z)
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
@@ -650,20 +661,23 @@ class ChatDetailViewModel @Inject constructor(
// Форматируем в локальное время: "14 апреля"
java.text.SimpleDateFormat("d MMMM", java.util.Locale("ru")).format(date)
} catch (e: Exception) {
dateString
android.util.Log.e("ChatDetailVM", "formatDateHeader error: ${e.message}", e)
dateString.take(10)
}
}
fun getLocalDateString(isoDate: String): String {
return try {
if (isoDate.isBlank()) return "1970-01-01"
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
val date = isoFormat.parse(isoDate) ?: return isoDate
val date = isoFormat.parse(isoDate) ?: return isoDate.take(10)
// Возвращаем просто дату YYYY-MM-DD в локальном часовом поясе для группировки
java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(date)
} catch (e: Exception) {
isoDate.split("T").first()
android.util.Log.e("ChatDetailVM", "getLocalDateString error: ${e.message}", e)
isoDate.split("T").firstOrNull() ?: "1970-01-01"
}
}

View File

@@ -31,6 +31,7 @@ import chats.domain.model.MediaType
import chats.domain.model.Media
import chats.domain.model.MessageStatus
import coil.compose.AsyncImage
import coil.request.ImageRequest
import core.presentation.components.AppVideoPlayer
import core.presentation.components.AppAudioPlayer
import core.presentation.components.AppAvatar
@@ -225,7 +226,10 @@ fun MessageBubble(
if (isReplyImage && reply.media.isNotEmpty()) {
AsyncImage(
model = reply.media.first().url,
model = ImageRequest.Builder(context)
.data(reply.media.first().url)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxHeight()
@@ -281,17 +285,24 @@ fun MessageBubble(
// GIF Content (Klipy)
if (message.mediaType == MediaType.GIF) {
AsyncImage(
model = message.content,
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
contentDescription = null,
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { /* Handle click */ },
contentScale = ContentScale.Crop
)
.clickable { /* Handle click */ }
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(message.content)
.crossfade(true)
.build(),
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Spacer(modifier = Modifier.height(4.dp))
}
@@ -321,17 +332,25 @@ fun MessageBubble(
val mediaItem = message.media[0]
when {
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
AsyncImage(
model = mediaItem.url,
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
contentDescription = null,
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { onMediaClick(mediaItem) },
contentScale = ContentScale.Crop
)
.clickable { onMediaClick(mediaItem) }
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(mediaItem.url)
.crossfade(true)
.memoryCacheKey(mediaItem.url)
.build(),
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
message.mediaType == MediaType.VIDEO || mediaItem.type.startsWith("video") -> {
Box(
@@ -708,7 +727,10 @@ fun GridItem(media: Media, modifier: Modifier = Modifier) {
Box(modifier = modifier.background(Color.White.copy(alpha = 0.1f))) {
if (isImage || isVideo) {
AsyncImage(
model = media.url,
model = ImageRequest.Builder(context)
.data(media.url)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop