Рабочие счетчики и переработка чата

This commit is contained in:
Халимов Рустам
2026-04-15 01:14:55 +03:00
parent 58fdf1aca1
commit 487cb1b12b
18 changed files with 725 additions and 292 deletions
@@ -22,12 +22,18 @@ import kotlin.math.roundToInt
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.zIndex
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import chats.presentation.components.MediaPicker
import chats.presentation.components.MessageBubble
import core.presentation.components.AppAvatar
@@ -55,6 +61,11 @@ fun ChatDetailScreen(
viewModel: ChatDetailViewModel,
onBack: () -> Unit
) {
DisposableEffect(Unit) {
onDispose {
viewModel.clearChatId()
}
}
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val listState = rememberLazyListState()
@@ -120,27 +131,97 @@ fun ChatDetailScreen(
viewModel.setChatId(chatId)
}
// Автопрокрутка к первому непрочитанному или к самому низу
LaunchedEffect(state.initialScrollIndex) {
state.initialScrollIndex?.let { index ->
if (index < state.messages.size) {
listState.scrollToItem(index)
val listItems = remember(state.messages) {
val items = mutableListOf<MessageListItem>()
if (state.messages.isEmpty()) return@remember items
// Группируем от новых к старым (Index 0 = bottom)
var i = 0
while (i < state.messages.size) {
val isoDate = state.messages[i].createdAt
// Получаем дату в локальном часовом поясе (например "2024-04-15")
val localDate = viewModel.getLocalDateString(isoDate)
val dayMessages = mutableListOf<chats.domain.model.Message>()
// Собираем все сообщения за этот локальный день
while (i < state.messages.size && viewModel.getLocalDateString(state.messages[i].createdAt) == localDate) {
dayMessages.add(state.messages[i])
i++
}
// Добавляем сообщения в инвертированном порядке
dayMessages.forEach { msg ->
items.add(MessageListItem.MessageItem(msg))
}
// Добавляем заголовок (над группой сообщений)
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
}
items
}
// Отслеживаем текущую дату для плавающего заголовка
val floatingDate by remember {
derivedStateOf {
val firstIndex = listState.firstVisibleItemIndex
// Не показываем плашку на самом первом элементе (там и так дата)
// И показываем только если мы прокрутили хотя бы немного
if (firstIndex > 0 && firstIndex < listItems.size) {
val item = listItems[firstIndex]
if (item is MessageListItem.MessageItem) {
viewModel.formatDateHeader(item.message.createdAt)
} else if (item is MessageListItem.DateHeader) {
item.date
} else null
} else null
}
}
// Показывать ли кнопку "вниз"
// Она должна появляться если мы не в самом низу
val showScrollToBottom by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
if (layoutInfo.totalItemsCount == 0) return@derivedStateOf false
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
// Показываем, если не в самом низу (с запасом 1-2 элемента)
lastVisibleIndex < listItems.size - 2
}
}
// Автоматическая прокрутка и прочтение при инициализации и новых сообщениях
LaunchedEffect(listItems.size) {
if (listItems.isNotEmpty()) {
if (state.initialScrollIndex != null && state.initialScrollIndex != -1) {
// Первый вход в чат - мы и так внизу из-за reverseLayout=true, но можем форсированно прокрутить к 0
listState.scrollToItem(0)
viewModel.onInitialScrollDone()
viewModel.markAsRead()
}
}
}
// Автопрокрутка к новому сообщению, если мы внизу
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) {
val isAtBottom = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.messages.size - 2
if (isAtBottom || state.initialScrollIndex == null) {
listState.animateScrollToItem(state.messages.size - 1)
}
// Если пришли новые сообщения и мы их видим — помечаем как прочитанные
if (isAtBottom) {
viewModel.markAsRead()
// Подгрузка истории при прокрутке вверх
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
.collect { lastVisibleIndex ->
if (lastVisibleIndex != null && lastVisibleIndex >= listItems.size - 10 && listItems.size >= 15) {
android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, size ${listItems.size}")
viewModel.loadMoreMessages()
}
}
}
val unreadCount = remember(state.messages) {
state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() }
}
// Авто-прочитка при нахождении внизу списка (в reverseLayout это индекс 0)
LaunchedEffect(listState.isScrollInProgress, listState.firstVisibleItemIndex) {
if (!listState.isScrollInProgress && listState.firstVisibleItemIndex <= 1) {
viewModel.markAsRead()
}
}
@@ -200,6 +281,7 @@ fun ChatDetailScreen(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
reverseLayout = true,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -207,34 +289,76 @@ fun ChatDetailScreen(
msg.media.map { it to msg.id }
}.reversed()
items(state.messages, key = { it.id }) { message ->
MessageBubble(
message = message,
isCurrentUser = message.senderId == viewModel.getCurrentUserId(),
autoPlay = message.id == autoPlayingMessageId,
initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) },
onReactionClick = { emoji -> viewModel.addReaction(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) {
// Ошибка (нет софта)
items(listItems, key = {
when(it) {
is MessageListItem.MessageItem -> it.message.id
is MessageListItem.DateHeader -> "date_${it.date}"
MessageListItem.UnreadSeparator -> "unread_separator"
}
}) { item ->
when(item) {
is MessageListItem.MessageItem -> {
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) },
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 -> {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Surface(
color = Color.Black.copy(alpha = 0.2f),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = item.date,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = Color.White
)
}
}
}
)
MessageListItem.UnreadSeparator -> {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f).height(0.5.dp).background(Color.Gray.copy(alpha = 0.3f)))
Text(
text = "НЕПРОЧИТАННЫЕ СООБЩЕНИЯ",
modifier = Modifier.padding(horizontal = 12.dp),
style = MaterialTheme.typography.labelSmall,
color = Color.Gray.copy(alpha = 0.6f)
)
Box(Modifier.weight(1f).height(0.5.dp).background(Color.Gray.copy(alpha = 0.3f)))
}
}
}
}
}
}
@@ -242,6 +366,72 @@ fun ChatDetailScreen(
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
// Плавающий заголовок даты (на самом верху списка под топбаром)
floatingDate?.let { date ->
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.align(Alignment.TopCenter)
.zIndex(1f), // Гарантируем видимость поверх всего
contentAlignment = Alignment.TopCenter
) {
Surface(
color = Color.DarkGray.copy(alpha = 0.9f),
shape = CircleShape,
shadowElevation = 4.dp
) {
Text(
text = date,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = Color.White
)
}
}
}
// Кнопка прокрутки вниз с бейджем
if (showScrollToBottom) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 16.dp, end = 16.dp)
) {
FloatingActionButton(
onClick = {
scope.launch {
listState.animateScrollToItem(listItems.size - 1)
}
},
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary,
shape = CircleShape,
modifier = Modifier.size(44.dp),
elevation = FloatingActionButtonDefaults.elevation(2.dp)
) {
Icon(Icons.Default.KeyboardArrowDown, contentDescription = null)
}
if (unreadCount > 0) {
Surface(
color = Color(0xFFF44336), // Красный как в вебе
shape = CircleShape,
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = 4.dp, y = (-4).dp)
) {
Text(
text = if (unreadCount > 99) "99+" else unreadCount.toString(),
color = Color.White,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
}
// Панель ввода
@@ -421,6 +611,21 @@ fun ChatDetailScreen(
}
},
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Send,
capitalization = KeyboardCapitalization.Sentences
),
keyboardActions = KeyboardActions(
onSend = {
if (textInput.isNotBlank() || state.pendingAttachments.isNotEmpty()) {
viewModel.sendMessage(textInput, onFail = { failedText ->
textInput = failedText
})
textInput = ""
isEmojiPickerVisible = false
}
}
),
decorationBox = { innerTextField ->
if (textInput.isEmpty()) {
Text(
@@ -443,8 +648,11 @@ fun ChatDetailScreen(
Surface(
onClick = {
if (!showMic) {
viewModel.sendMessage(textInput)
viewModel.sendMessage(textInput, onFail = { failedText ->
textInput = failedText
})
textInput = ""
isEmojiPickerVisible = false
}
},
shape = RoundedCornerShape(16.dp),
@@ -566,3 +774,9 @@ fun ChatDetailScreen(
)
}
}
sealed class MessageListItem {
data class MessageItem(val message: chats.domain.model.Message) : MessageListItem()
data class DateHeader(val date: String) : MessageListItem()
object UnreadSeparator : MessageListItem()
}