diff --git a/client-mobile/app/build.gradle.kts b/client-mobile/app/build.gradle.kts index 56ac671..3885839 100644 --- a/client-mobile/app/build.gradle.kts +++ b/client-mobile/app/build.gradle.kts @@ -61,6 +61,10 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.8" } + // Включаем оптимизации компилятора Compose + kotlinOptions { + jvmTarget = "17" + } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" diff --git a/client-mobile/app/src/main/AndroidManifest.xml b/client-mobile/app/src/main/AndroidManifest.xml index 3e0e000..d06a980 100644 --- a/client-mobile/app/src/main/AndroidManifest.xml +++ b/client-mobile/app/src/main/AndroidManifest.xml @@ -31,7 +31,7 @@ diff --git a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt index ed22760..9fa09f0 100644 --- a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt +++ b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt @@ -9,7 +9,7 @@ import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import dagger.hilt.android.AndroidEntryPoint import navigation.AppNavigation -import core.presentation.theme.ForkMessengerTheme +import core.presentation.theme.KnotTheme @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -38,7 +38,7 @@ class MainActivity : ComponentActivity() { } setContent { - ForkMessengerTheme { + KnotTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background diff --git a/client-mobile/app/src/main/res/drawable/ic_notification.xml b/client-mobile/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..f99d835 --- /dev/null +++ b/client-mobile/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/client-mobile/app/src/main/res/values-en/strings.xml b/client-mobile/app/src/main/res/values-en/strings.xml index 8654653..cf42e3d 100644 --- a/client-mobile/app/src/main/res/values-en/strings.xml +++ b/client-mobile/app/src/main/res/values-en/strings.xml @@ -1,5 +1,5 @@ - ForkMessenger + Knot Login Register Username diff --git a/client-mobile/app/src/main/res/values/strings.xml b/client-mobile/app/src/main/res/values/strings.xml index b3a476a..d9ea064 100644 --- a/client-mobile/app/src/main/res/values/strings.xml +++ b/client-mobile/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - ForkMessenger + Knot Войти Регистрация Имя пользователя diff --git a/client-mobile/chats/domain/model/ChatModels.kt b/client-mobile/chats/domain/model/ChatModels.kt index af9ae09..34d56c7 100644 --- a/client-mobile/chats/domain/model/ChatModels.kt +++ b/client-mobile/chats/domain/model/ChatModels.kt @@ -1,5 +1,8 @@ package chats.domain.model +import androidx.compose.runtime.Immutable + +@Immutable data class Chat( val id: String, val type: String, diff --git a/client-mobile/chats/domain/model/Message.kt b/client-mobile/chats/domain/model/Message.kt index 514b293..d8e6c2d 100644 --- a/client-mobile/chats/domain/model/Message.kt +++ b/client-mobile/chats/domain/model/Message.kt @@ -1,5 +1,8 @@ package chats.domain.model +import androidx.compose.runtime.Immutable + +@Immutable data class Message( val id: String, val chatId: String, @@ -19,6 +22,7 @@ data class Message( val replyTo: Message? = null ) +@Immutable data class Media( val id: String, val type: String, diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index 071dc32..64b3c30 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -152,39 +152,46 @@ fun ChatDetailScreen( val items = mutableListOf() if (state.messages.isEmpty()) return@remember items - // Группируем от новых к старым (Index 0 = bottom) + // Группируем от старых к новым (для normal layout) + val reversedMessages = state.messages.reversed() var i = 0 - while (i < state.messages.size) { - val isoDate = state.messages[i].createdAt - // Получаем дату в локальном часовом поясе (например "2024-04-15") + while (i < reversedMessages.size) { + val isoDate = reversedMessages[i].createdAt val localDate = viewModel.getLocalDateString(isoDate) val dayMessages = mutableListOf() - // Собираем все сообщения за этот локальный день - while (i < state.messages.size && viewModel.getLocalDateString(state.messages[i].createdAt) == localDate) { - dayMessages.add(state.messages[i]) + 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)) } - - // Добавляем заголовок (над группой сообщений) - items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate))) } items } + // Выносим allChatMedia наружу - до LazyColumn + val allChatMedia = remember(state.messages) { + state.messages.flatMap { msg -> msg.media.map { it to msg.id } } + } + // Отслеживаем текущую дату для плавающего заголовка val floatingDate by remember { derivedStateOf { - val firstIndex = listState.firstVisibleItemIndex - // Не показываем плашку на самом первом элементе (там и так дата) - // И показываем только если мы прокрутили хотя бы немного - if (firstIndex > 0 && firstIndex < listItems.size) { - val item = listItems[firstIndex] + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) return@derivedStateOf null + + // Находим первый видимый элемент + val firstVisibleItemIndex = visibleItems.first().index + if (firstVisibleItemIndex > 0 && firstVisibleItemIndex < listItems.size) { + val item = listItems[firstVisibleItemIndex] if (item is MessageListItem.MessageItem) { viewModel.formatDateHeader(item.message.createdAt) } else if (item is MessageListItem.DateHeader) { @@ -195,67 +202,77 @@ fun ChatDetailScreen( } // Показывать ли кнопку "вниз" - // Она должна появляться если мы не в самом низу - // Проверка видимости кнопки скролла вниз val showScrollToBottom by remember { 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 - LaunchedEffect(firstMessageId) { - if (firstMessageId != null) { - // Если мы уже внизу (индекс 0) или это наше сообщение - скроллим вниз - val isAtBottom = listState.firstVisibleItemIndex <= 1 - val isMyMessage = state.messages.firstOrNull()?.senderId == viewModel.getCurrentUserId() + // Авто-скролл к новым сообщениям - только если мы уже внизу + val lastMessageId = state.messages.lastOrNull()?.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 || isMyMessage) { - listState.animateScrollToItem(0) - // Если мы внизу - помечаем сразу как прочитанное - if (isAtBottom && !isMyMessage) { - viewModel.markAsRead() - } + if (isAtBottom) { + listState.animateScrollToItem(listItems.size - 1) } } } - // Автоматическая прокрутка и прочтение при инициализации и новых сообщениях + // Автоматическая прокрутка при инициализации LaunchedEffect(listItems.size) { if (listItems.isNotEmpty()) { - if (state.initialScrollIndex != null && state.initialScrollIndex != -1) { - // Первый вход в чат - мы и так внизу из-за reverseLayout=true, но можем форсированно прокрутить к 0 - listState.scrollToItem(0) - viewModel.onInitialScrollDone() - viewModel.markAsRead() - } + listState.scrollToItem(listItems.size - 1) + viewModel.onInitialScrollDone() + viewModel.markAsRead() } } // Подгрузка истории при прокрутке вверх - LaunchedEffect(listState, state.messages.size) { - snapshotFlow { - val lastIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - val totalCount = listState.layoutInfo.totalItemsCount - lastIndex to totalCount - } - .collect { (lastVisibleIndex, totalCount) -> - if (totalCount > 0 && lastVisibleIndex >= totalCount - 20 && !state.isLoadingMore) { - android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, total $totalCount") - viewModel.loadMoreMessages() + LaunchedEffect(listState) { + snapshotFlow { listState.layoutInfo } + .collect { layoutInfo -> + val visibleItems = layoutInfo.visibleItemsInfo + val totalCount = layoutInfo.totalItemsCount + if (totalCount == 0 || visibleItems.isEmpty()) return@collect + + val firstVisibleIndex = visibleItems.first().index + if (firstVisibleIndex < 5 && !state.isLoadingMore) { + android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $firstVisibleIndex") + 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() + // Авто-прочитка при нахождении внизу списка + LaunchedEffect(listState.isScrollInProgress) { + if (!listState.isScrollInProgress) { + 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( state = listState, modifier = Modifier.fillMaxSize(), - reverseLayout = true, contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - val allChatMedia = state.messages.flatMap { msg -> - msg.media.map { it to msg.id } - }.reversed() - - items(listItems, key = { - when(it) { - is MessageListItem.MessageItem -> it.message.id - is MessageListItem.DateHeader -> "date_${it.date}" - MessageListItem.UnreadSeparator -> "unread_separator" + items( + items = listItems, + key = { item -> + when(item) { + is MessageListItem.MessageItem -> "msg_${item.message.id}" + is MessageListItem.DateHeader -> "date_${item.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) { is MessageListItem.MessageItem -> { SwipeableMessageItem( @@ -487,7 +509,7 @@ fun ChatDetailScreen( .border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape) .clickable { scope.launch { - listState.animateScrollToItem(0) + listState.animateScrollToItem(listItems.size - 1) } }, contentAlignment = Alignment.Center @@ -1032,6 +1054,7 @@ fun ForwardChatSelectionDialog( ) } +@Immutable sealed class MessageListItem { data class MessageItem(val message: chats.domain.model.Message) : MessageListItem() data class DateHeader(val date: String) : MessageListItem() diff --git a/client-mobile/chats/presentation/components/MediaPicker.kt b/client-mobile/chats/presentation/components/MediaPicker.kt index 583dd18..7fadf33 100644 --- a/client-mobile/chats/presentation/components/MediaPicker.kt +++ b/client-mobile/chats/presentation/components/MediaPicker.kt @@ -200,7 +200,9 @@ private fun EmojiContent( LazyVerticalGrid( columns = GridCells.Fixed(8), 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 -> // Заголовок категории @@ -214,7 +216,7 @@ private fun EmojiContent( ) } - items(category.emojis) { emoji -> + items(category.emojis, key = { emoji -> emoji }) { emoji -> Box( modifier = Modifier .aspectRatio(1f) @@ -362,9 +364,11 @@ private fun GifContent( LazyVerticalGrid( columns = GridCells.Fixed(3), 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 ?: gif.files?.get("sd")?.get("gif")?.url ?: gif.file?.get("hd")?.get("gif")?.url diff --git a/client-mobile/chats/presentation/components/SwipeableMessageItem.kt b/client-mobile/chats/presentation/components/SwipeableMessageItem.kt index c05a147..09d34e7 100644 --- a/client-mobile/chats/presentation/components/SwipeableMessageItem.kt +++ b/client-mobile/chats/presentation/components/SwipeableMessageItem.kt @@ -1,6 +1,5 @@ package chats.presentation.components -import androidx.compose.animation.core.Animatable import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable @@ -30,10 +29,10 @@ fun SwipeableMessageItem( ) { val density = LocalDensity.current val scope = rememberCoroutineScope() - val offsetX = remember { Animatable(0f) } val replyThreshold = with(density) { 60.dp.toPx() } val maxDrag = with(density) { 90.dp.toPx() } + var offsetX by remember { mutableFloatStateOf(0f) } var isTriggered by remember { mutableStateOf(false) } Box( @@ -42,27 +41,18 @@ fun SwipeableMessageItem( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> - scope.launch { - // We only allow swiping to the left (negative delta) - val newOffset = (offsetX.value + delta).coerceIn(-maxDrag, 0f) - offsetX.snapTo(newOffset) - - if (newOffset <= -replyThreshold && !isTriggered) { - isTriggered = true - // Trigger haptic feedback if available? - } else if (newOffset > -replyThreshold) { - isTriggered = false - } - } + // We only allow swiping to the left (negative delta) + val newOffset = (offsetX + delta).coerceIn(-maxDrag, 0f) + offsetX = newOffset + + isTriggered = newOffset <= -replyThreshold }, onDragStopped = { - if (offsetX.value <= -replyThreshold) { + if (offsetX <= -replyThreshold) { onReply() } - scope.launch { - offsetX.animateTo(0f) - isTriggered = false - } + offsetX = 0f + isTriggered = false } ) ) { @@ -72,7 +62,7 @@ fun SwipeableMessageItem( .align(Alignment.CenterEnd) .padding(end = 16.dp) .size(36.dp) - .alpha(((-offsetX.value) / replyThreshold).coerceIn(0f, 1f)) + .alpha(((-offsetX) / replyThreshold).coerceIn(0f, 1f)) .clip(CircleShape) .background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)), contentAlignment = Alignment.Center @@ -88,7 +78,7 @@ fun SwipeableMessageItem( // Message Content Box( modifier = Modifier - .offset { IntOffset(offsetX.value.roundToInt(), 0) } + .offset { IntOffset(offsetX.roundToInt(), 0) } .fillMaxWidth() ) { content() diff --git a/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt b/client-mobile/core/notifications/data/KnotFirebaseMessagingService.kt similarity index 86% rename from client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt rename to client-mobile/core/notifications/data/KnotFirebaseMessagingService.kt index b6a00ca..434afda 100644 --- a/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt +++ b/client-mobile/core/notifications/data/KnotFirebaseMessagingService.kt @@ -1,12 +1,5 @@ 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.RemoteMessage import auth.data.remote.api.AuthApi @@ -18,7 +11,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint -class ForkFirebaseMessagingService : FirebaseMessagingService() { +class KnotFirebaseMessagingService : FirebaseMessagingService() { @Inject lateinit var authApi: AuthApi diff --git a/client-mobile/core/notifications/data/NotificationHelper.kt b/client-mobile/core/notifications/data/NotificationHelper.kt index fa57e83..afb0e70 100644 --- a/client-mobile/core/notifications/data/NotificationHelper.kt +++ b/client-mobile/core/notifications/data/NotificationHelper.kt @@ -7,9 +7,10 @@ import android.content.Context import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat +import ru.knot.messager.R 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) { val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -19,7 +20,7 @@ object NotificationHelper { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, - "Fork Messenger Notifications", + "Knot Notifications", NotificationManager.IMPORTANCE_HIGH ).apply { description = "Chat and system notifications" @@ -42,7 +43,7 @@ object NotificationHelper { ) val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_dialog_info) + .setSmallIcon(R.drawable.ic_notification) .setContentTitle(title) .setContentText(body) .setAutoCancel(true) diff --git a/client-mobile/core/presentation/theme/Theme.kt b/client-mobile/core/presentation/theme/Theme.kt index d655276..3cb7472 100644 --- a/client-mobile/core/presentation/theme/Theme.kt +++ b/client-mobile/core/presentation/theme/Theme.kt @@ -28,7 +28,7 @@ private val DarkColors = darkColorScheme( val SoftSquareShape = RoundedCornerShape(14.dp) // "Мягкий квадрат" как в вэб @Composable -fun ForkMessengerTheme(content: @Composable () -> Unit) { +fun KnotTheme(content: @Composable () -> Unit) { MaterialTheme( colorScheme = DarkColors, shapes = MaterialTheme.shapes.copy( diff --git a/client-mobile/profiles/presentation/ProfileScreen.kt b/client-mobile/profiles/presentation/ProfileScreen.kt index 4413f3b..29a06aa 100644 --- a/client-mobile/profiles/presentation/ProfileScreen.kt +++ b/client-mobile/profiles/presentation/ProfileScreen.kt @@ -284,8 +284,8 @@ fun LazyListScope.renderFiles() { fun LazyListScope.renderLinks() { items(2) { ListItem( - headlineContent = { Text("https://github.com/forkmessager") }, - supportingContent = { Text("GitHub - ForkMessager Project") }, + headlineContent = { Text("https://github.com/knot-messenger") }, + supportingContent = { Text("GitHub - Knot Project") }, leadingContent = { Icon(Icons.Default.Link, contentDescription = null) } ) }