Почти плавный скрол чата

This commit is contained in:
Халимов Рустам
2026-04-28 00:16:52 +03:00
parent d9a20e40b0
commit 46c22300e9
15 changed files with 145 additions and 110 deletions
+4
View File
@@ -61,6 +61,10 @@ android {
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.8" kotlinCompilerExtensionVersion = "1.5.8"
} }
// Включаем оптимизации компилятора Compose
kotlinOptions {
jvmTarget = "17"
}
packaging { packaging {
resources { resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -31,7 +31,7 @@
</activity> </activity>
<service <service
android:name="core.notifications.data.ForkFirebaseMessagingService" android:name="core.notifications.data.KnotFirebaseMessagingService"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" /> <action android:name="com.google.firebase.MESSAGING_EVENT" />
@@ -9,7 +9,7 @@ import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import navigation.AppNavigation import navigation.AppNavigation
import core.presentation.theme.ForkMessengerTheme import core.presentation.theme.KnotTheme
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -38,7 +38,7 @@ class MainActivity : ComponentActivity() {
} }
setContent { setContent {
ForkMessengerTheme { KnotTheme {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Цветная иконка для notification -->
<path
android:fillColor="#6C5CE7"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"/>
</vector>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">ForkMessenger</string> <string name="app_name">Knot</string>
<string name="login">Login</string> <string name="login">Login</string>
<string name="register">Register</string> <string name="register">Register</string>
<string name="username">Username</string> <string name="username">Username</string>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">ForkMessenger</string> <string name="app_name">Knot</string>
<string name="login">Войти</string> <string name="login">Войти</string>
<string name="register">Регистрация</string> <string name="register">Регистрация</string>
<string name="username">Имя пользователя</string> <string name="username">Имя пользователя</string>
@@ -1,5 +1,8 @@
package chats.domain.model package chats.domain.model
import androidx.compose.runtime.Immutable
@Immutable
data class Chat( data class Chat(
val id: String, val id: String,
val type: String, val type: String,
@@ -1,5 +1,8 @@
package chats.domain.model package chats.domain.model
import androidx.compose.runtime.Immutable
@Immutable
data class Message( data class Message(
val id: String, val id: String,
val chatId: String, val chatId: String,
@@ -19,6 +22,7 @@ data class Message(
val replyTo: Message? = null val replyTo: Message? = null
) )
@Immutable
data class Media( data class Media(
val id: String, val id: String,
val type: String, val type: String,
@@ -152,39 +152,46 @@ fun ChatDetailScreen(
val items = mutableListOf<MessageListItem>() val items = mutableListOf<MessageListItem>()
if (state.messages.isEmpty()) return@remember items if (state.messages.isEmpty()) return@remember items
// Группируем от новых к старым (Index 0 = bottom) // Группируем от старых к новым (для normal layout)
val reversedMessages = state.messages.reversed()
var i = 0 var i = 0
while (i < state.messages.size) { while (i < reversedMessages.size) {
val isoDate = state.messages[i].createdAt val isoDate = reversedMessages[i].createdAt
// Получаем дату в локальном часовом поясе (например "2024-04-15")
val localDate = viewModel.getLocalDateString(isoDate) val localDate = viewModel.getLocalDateString(isoDate)
val dayMessages = mutableListOf<chats.domain.model.Message>() val dayMessages = mutableListOf<chats.domain.model.Message>()
// Собираем все сообщения за этот локальный день while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) {
while (i < state.messages.size && viewModel.getLocalDateString(state.messages[i].createdAt) == localDate) { dayMessages.add(reversedMessages[i])
dayMessages.add(state.messages[i])
i++ i++
} }
// Добавляем сообщения в инвертированном порядке // Добавляем заголовок даты
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
// Добавляем сообщения
dayMessages.forEach { msg -> dayMessages.forEach { msg ->
items.add(MessageListItem.MessageItem(msg)) items.add(MessageListItem.MessageItem(msg))
} }
// Добавляем заголовок (над группой сообщений)
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
} }
items items
} }
// Выносим allChatMedia наружу - до LazyColumn
val allChatMedia = remember(state.messages) {
state.messages.flatMap { msg -> msg.media.map { it to msg.id } }
}
// Отслеживаем текущую дату для плавающего заголовка // Отслеживаем текущую дату для плавающего заголовка
val floatingDate by remember { val floatingDate by remember {
derivedStateOf { derivedStateOf {
val firstIndex = listState.firstVisibleItemIndex val layoutInfo = listState.layoutInfo
// Не показываем плашку на самом первом элементе (там и так дата) val visibleItems = layoutInfo.visibleItemsInfo
// И показываем только если мы прокрутили хотя бы немного if (visibleItems.isEmpty()) return@derivedStateOf null
if (firstIndex > 0 && firstIndex < listItems.size) {
val item = listItems[firstIndex] // Находим первый видимый элемент
val firstVisibleItemIndex = visibleItems.first().index
if (firstVisibleItemIndex > 0 && firstVisibleItemIndex < listItems.size) {
val item = listItems[firstVisibleItemIndex]
if (item is MessageListItem.MessageItem) { if (item is MessageListItem.MessageItem) {
viewModel.formatDateHeader(item.message.createdAt) viewModel.formatDateHeader(item.message.createdAt)
} else if (item is MessageListItem.DateHeader) { } else if (item is MessageListItem.DateHeader) {
@@ -195,67 +202,77 @@ fun ChatDetailScreen(
} }
// Показывать ли кнопку "вниз" // Показывать ли кнопку "вниз"
// Она должна появляться если мы не в самом низу
// Проверка видимости кнопки скролла вниз
val showScrollToBottom by remember { val showScrollToBottom by remember {
derivedStateOf { derivedStateOf {
listState.firstVisibleItemIndex > 5 val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val totalCount = layoutInfo.totalItemsCount
if (totalCount == 0) return@derivedStateOf false
// Проверяем, видим ли мы последний элемент
val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: 0
lastVisibleIndex < totalCount - 1
} }
} }
// 4. Авто-скролл к новым сообщениям // Авто-скролл к новым сообщениям - только если мы уже внизу
val firstMessageId = state.messages.firstOrNull()?.id val lastMessageId = state.messages.lastOrNull()?.id
LaunchedEffect(firstMessageId) { LaunchedEffect(lastMessageId) {
if (firstMessageId != null) { if (lastMessageId != null && listItems.isNotEmpty()) {
// Если мы уже внизу (индекс 0) или это наше сообщение - скроллим вниз val isAtBottom = listState.layoutInfo.run {
val isAtBottom = listState.firstVisibleItemIndex <= 1 val visibleItems = visibleItemsInfo
val isMyMessage = state.messages.firstOrNull()?.senderId == viewModel.getCurrentUserId() val totalCount = totalItemsCount
if (totalCount == 0 || visibleItems.isEmpty()) return@run true
visibleItems.last().index >= totalCount - 2
}
if (isAtBottom || isMyMessage) { if (isAtBottom) {
listState.animateScrollToItem(0) listState.animateScrollToItem(listItems.size - 1)
// Если мы внизу - помечаем сразу как прочитанное
if (isAtBottom && !isMyMessage) {
viewModel.markAsRead()
}
} }
} }
} }
// Автоматическая прокрутка и прочтение при инициализации и новых сообщениях // Автоматическая прокрутка при инициализации
LaunchedEffect(listItems.size) { LaunchedEffect(listItems.size) {
if (listItems.isNotEmpty()) { if (listItems.isNotEmpty()) {
if (state.initialScrollIndex != null && state.initialScrollIndex != -1) { listState.scrollToItem(listItems.size - 1)
// Первый вход в чат - мы и так внизу из-за reverseLayout=true, но можем форсированно прокрутить к 0 viewModel.onInitialScrollDone()
listState.scrollToItem(0) viewModel.markAsRead()
viewModel.onInitialScrollDone()
viewModel.markAsRead()
}
} }
} }
// Подгрузка истории при прокрутке вверх // Подгрузка истории при прокрутке вверх
LaunchedEffect(listState, state.messages.size) { LaunchedEffect(listState) {
snapshotFlow { snapshotFlow { listState.layoutInfo }
val lastIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 .collect { layoutInfo ->
val totalCount = listState.layoutInfo.totalItemsCount val visibleItems = layoutInfo.visibleItemsInfo
lastIndex to totalCount val totalCount = layoutInfo.totalItemsCount
} if (totalCount == 0 || visibleItems.isEmpty()) return@collect
.collect { (lastVisibleIndex, totalCount) ->
if (totalCount > 0 && lastVisibleIndex >= totalCount - 20 && !state.isLoadingMore) { val firstVisibleIndex = visibleItems.first().index
android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, total $totalCount") if (firstVisibleIndex < 5 && !state.isLoadingMore) {
viewModel.loadMoreMessages() android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $firstVisibleIndex")
viewModel.loadMoreMessages()
}
} }
}
} }
val unreadCount = remember(state.messages) { val unreadCount = remember(state.messages) {
state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() } state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() }
} }
// Авто-прочитка при нахождении внизу списка (в reverseLayout это индекс 0) // Авто-прочитка при нахождении внизу списка
LaunchedEffect(listState.isScrollInProgress, listState.firstVisibleItemIndex) { LaunchedEffect(listState.isScrollInProgress) {
if (!listState.isScrollInProgress && listState.firstVisibleItemIndex <= 1) { if (!listState.isScrollInProgress) {
viewModel.markAsRead() 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()
}
}
} }
} }
@@ -338,21 +355,26 @@ fun ChatDetailScreen(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
reverseLayout = true,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
val allChatMedia = state.messages.flatMap { msg -> items(
msg.media.map { it to msg.id } items = listItems,
}.reversed() key = { item ->
when(item) {
items(listItems, key = { is MessageListItem.MessageItem -> "msg_${item.message.id}"
when(it) { is MessageListItem.DateHeader -> "date_${item.date}"
is MessageListItem.MessageItem -> it.message.id MessageListItem.UnreadSeparator -> "unread_separator"
is MessageListItem.DateHeader -> "date_${it.date}" }
MessageListItem.UnreadSeparator -> "unread_separator" },
contentType = { item ->
when(item) {
is MessageListItem.MessageItem -> "message"
is MessageListItem.DateHeader -> "date_header"
MessageListItem.UnreadSeparator -> "unread_separator"
}
} }
}) { item -> ) { item ->
when(item) { when(item) {
is MessageListItem.MessageItem -> { is MessageListItem.MessageItem -> {
SwipeableMessageItem( SwipeableMessageItem(
@@ -487,7 +509,7 @@ fun ChatDetailScreen(
.border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape) .border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape)
.clickable { .clickable {
scope.launch { scope.launch {
listState.animateScrollToItem(0) listState.animateScrollToItem(listItems.size - 1)
} }
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -1032,6 +1054,7 @@ fun ForwardChatSelectionDialog(
) )
} }
@Immutable
sealed class MessageListItem { sealed class MessageListItem {
data class MessageItem(val message: chats.domain.model.Message) : MessageListItem() data class MessageItem(val message: chats.domain.model.Message) : MessageListItem()
data class DateHeader(val date: String) : MessageListItem() data class DateHeader(val date: String) : MessageListItem()
@@ -200,7 +200,9 @@ private fun EmojiContent(
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(8), columns = GridCells.Fixed(8),
state = scrollState, state = scrollState,
modifier = Modifier.weight(1f).padding(horizontal = 4.dp) modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
emojiCategories.forEach { category -> emojiCategories.forEach { category ->
// Заголовок категории // Заголовок категории
@@ -214,7 +216,7 @@ private fun EmojiContent(
) )
} }
items(category.emojis) { emoji -> items(category.emojis, key = { emoji -> emoji }) { emoji ->
Box( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .aspectRatio(1f)
@@ -362,9 +364,11 @@ private fun GifContent(
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(3), columns = GridCells.Fixed(3),
modifier = Modifier.weight(1f).padding(4.dp), modifier = Modifier.weight(1f).padding(4.dp),
contentPadding = PaddingValues(4.dp) contentPadding = PaddingValues(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items(displayGifs) { gif -> items(displayGifs, key = { gif -> gif.id }, contentType = { "gif" }) { gif ->
val url = gif.files?.get("hd")?.get("gif")?.url val url = gif.files?.get("hd")?.get("gif")?.url
?: gif.files?.get("sd")?.get("gif")?.url ?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("hd")?.get("gif")?.url ?: gif.file?.get("hd")?.get("gif")?.url
@@ -1,6 +1,5 @@
package chats.presentation.components package chats.presentation.components
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.draggable
@@ -30,10 +29,10 @@ fun SwipeableMessageItem(
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val offsetX = remember { Animatable(0f) }
val replyThreshold = with(density) { 60.dp.toPx() } val replyThreshold = with(density) { 60.dp.toPx() }
val maxDrag = with(density) { 90.dp.toPx() } val maxDrag = with(density) { 90.dp.toPx() }
var offsetX by remember { mutableFloatStateOf(0f) }
var isTriggered by remember { mutableStateOf(false) } var isTriggered by remember { mutableStateOf(false) }
Box( Box(
@@ -42,27 +41,18 @@ fun SwipeableMessageItem(
.draggable( .draggable(
orientation = Orientation.Horizontal, orientation = Orientation.Horizontal,
state = rememberDraggableState { delta -> state = rememberDraggableState { delta ->
scope.launch { // We only allow swiping to the left (negative delta)
// We only allow swiping to the left (negative delta) val newOffset = (offsetX + delta).coerceIn(-maxDrag, 0f)
val newOffset = (offsetX.value + delta).coerceIn(-maxDrag, 0f) offsetX = newOffset
offsetX.snapTo(newOffset)
isTriggered = newOffset <= -replyThreshold
if (newOffset <= -replyThreshold && !isTriggered) {
isTriggered = true
// Trigger haptic feedback if available?
} else if (newOffset > -replyThreshold) {
isTriggered = false
}
}
}, },
onDragStopped = { onDragStopped = {
if (offsetX.value <= -replyThreshold) { if (offsetX <= -replyThreshold) {
onReply() onReply()
} }
scope.launch { offsetX = 0f
offsetX.animateTo(0f) isTriggered = false
isTriggered = false
}
} }
) )
) { ) {
@@ -72,7 +62,7 @@ fun SwipeableMessageItem(
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.padding(end = 16.dp) .padding(end = 16.dp)
.size(36.dp) .size(36.dp)
.alpha(((-offsetX.value) / replyThreshold).coerceIn(0f, 1f)) .alpha(((-offsetX) / replyThreshold).coerceIn(0f, 1f))
.clip(CircleShape) .clip(CircleShape)
.background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)), .background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -88,7 +78,7 @@ fun SwipeableMessageItem(
// Message Content // Message Content
Box( Box(
modifier = Modifier modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) } .offset { IntOffset(offsetX.roundToInt(), 0) }
.fillMaxWidth() .fillMaxWidth()
) { ) {
content() content()
@@ -1,12 +1,5 @@
package core.notifications.data package core.notifications.data
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import auth.data.remote.api.AuthApi import auth.data.remote.api.AuthApi
@@ -18,7 +11,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ForkFirebaseMessagingService : FirebaseMessagingService() { class KnotFirebaseMessagingService : FirebaseMessagingService() {
@Inject @Inject
lateinit var authApi: AuthApi lateinit var authApi: AuthApi
@@ -7,9 +7,10 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import ru.knot.messager.R
object NotificationHelper { object NotificationHelper {
private const val CHANNEL_ID = "fork_notifications_channel" private const val CHANNEL_ID = "knot_notifications_channel"
fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null, totalCount: Int = 0) { fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null, totalCount: Int = 0) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -19,7 +20,7 @@ object NotificationHelper {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Fork Messenger Notifications", "Knot Notifications",
NotificationManager.IMPORTANCE_HIGH NotificationManager.IMPORTANCE_HIGH
).apply { ).apply {
description = "Chat and system notifications" description = "Chat and system notifications"
@@ -42,7 +43,7 @@ object NotificationHelper {
) )
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
.setAutoCancel(true) .setAutoCancel(true)
@@ -28,7 +28,7 @@ private val DarkColors = darkColorScheme(
val SoftSquareShape = RoundedCornerShape(14.dp) // "Мягкий квадрат" как в вэб val SoftSquareShape = RoundedCornerShape(14.dp) // "Мягкий квадрат" как в вэб
@Composable @Composable
fun ForkMessengerTheme(content: @Composable () -> Unit) { fun KnotTheme(content: @Composable () -> Unit) {
MaterialTheme( MaterialTheme(
colorScheme = DarkColors, colorScheme = DarkColors,
shapes = MaterialTheme.shapes.copy( shapes = MaterialTheme.shapes.copy(
@@ -284,8 +284,8 @@ fun LazyListScope.renderFiles() {
fun LazyListScope.renderLinks() { fun LazyListScope.renderLinks() {
items(2) { items(2) {
ListItem( ListItem(
headlineContent = { Text("https://github.com/forkmessager") }, headlineContent = { Text("https://github.com/knot-messenger") },
supportingContent = { Text("GitHub - ForkMessager Project") }, supportingContent = { Text("GitHub - Knot Project") },
leadingContent = { Icon(Icons.Default.Link, contentDescription = null) } leadingContent = { Icon(Icons.Default.Link, contentDescription = null) }
) )
} }