From 30ad6d1cc1dba5b2a37d080872381e6613c6da31 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 11 Jan 2026 04:58:25 +0500 Subject: [PATCH] feat: Enhance ChatDetailScreen and ChatViewModel with dynamic online status and typing indicators --- .../messenger/ui/chats/ChatDetailScreen.kt | 239 ++++++++++++++++-- .../messenger/ui/chats/ChatViewModel.kt | 179 ++++++++++++- 2 files changed, 389 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index b824287..92fd38b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -1,5 +1,6 @@ package com.rosetta.messenger.ui.chats +import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.background @@ -8,6 +9,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -63,13 +65,33 @@ data class ChatMessage( val text: String, val isOutgoing: Boolean, val timestamp: Date, - val status: MessageStatus = MessageStatus.SENT + val status: MessageStatus = MessageStatus.SENT, + val showDateHeader: Boolean = false // Показывать ли разделитель даты ) enum class MessageStatus { SENDING, SENT, DELIVERED, READ } +/** + * Получить текст даты (today, yesterday или полная дата) + */ +private fun getDateText(timestamp: Long): String { + val messageDate = Calendar.getInstance().apply { timeInMillis = timestamp } + val today = Calendar.getInstance() + val yesterday = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) } + + return when { + messageDate.get(Calendar.YEAR) == today.get(Calendar.YEAR) && + messageDate.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) -> "today" + + messageDate.get(Calendar.YEAR) == yesterday.get(Calendar.YEAR) && + messageDate.get(Calendar.DAY_OF_YEAR) == yesterday.get(Calendar.DAY_OF_YEAR) -> "yesterday" + + else -> SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH).format(Date(timestamp)) + } +} + // Extension для конвертации private fun Message.toChatMessage() = ChatMessage( id = messageId, @@ -98,15 +120,38 @@ fun ChatDetailScreen( viewModel: ChatViewModel = viewModel() ) { val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0) + // � Fade-in анимация для всего экрана + var isVisible by remember { mutableStateOf(false) } + val screenAlpha by animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + label = "screenFade" + ) + + LaunchedEffect(Unit) { + isVisible = true + } + + // 🔥 Быстрое закрытие клавиатуры и выход + val hideKeyboardAndBack = remember { + { + // Мгновенно убираем фокус и клавиатуру + focusManager.clearFocus(force = true) + keyboardController?.hide() + // Сразу выходим без задержки + onBack() + } + } + // Определяем это Saved Messages или обычный чат val isSavedMessages = user.publicKey == currentUserPublicKey val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) } - val chatSubtitle = if (isSavedMessages) "Notes" else if (user.online == 1) "online" else "last seen recently" // Состояние показа логов var showLogs by remember { mutableStateOf(false) } @@ -116,16 +161,63 @@ fun ChatDetailScreen( val messages by viewModel.messages.collectAsState() val inputText by viewModel.inputText.collectAsState() val isTyping by viewModel.opponentTyping.collectAsState() + val isOnline by viewModel.opponentOnline.collectAsState() + + // 🔥 Добавляем информацию о датах к сообщениям + val messagesWithDates = remember(messages) { + val result = mutableListOf>() // message, showDateHeader + var lastDateString = "" + + // Сортируем по времени (старые -> новые) + val sortedMessages = messages.sortedBy { it.timestamp.time } + + for (message in sortedMessages) { + val dateString = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(message.timestamp) + val showDate = dateString != lastDateString + result.add(message to showDate) + lastDateString = dateString + } + + result + } + + // Динамический subtitle: typing > online > offline + val chatSubtitle = when { + isSavedMessages -> "Notes" + isTyping -> "typing..." + isOnline -> "online" + else -> "offline" + } val listState = rememberLazyListState() val scope = rememberCoroutineScope() + // 🔥 Обработка системной кнопки назад + BackHandler { + hideKeyboardAndBack() + } + + // 🔥 Cleanup при выходе из экрана + DisposableEffect(Unit) { + onDispose { + focusManager.clearFocus() + keyboardController?.hide() + } + } + // Инициализируем ViewModel с ключами и открываем диалог LaunchedEffect(user.publicKey) { viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey) viewModel.openDialog(user.publicKey, user.title, user.username) } + // Отмечаем сообщения как прочитанные когда они видны + LaunchedEffect(messages) { + if (messages.isNotEmpty()) { + viewModel.markVisibleMessagesAsRead() + } + } + // Прокрутка при новых сообщениях LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { @@ -139,14 +231,20 @@ fun ChatDetailScreen( isDarkTheme ) - Scaffold( - topBar = { - // Кастомный TopAppBar для чата - Surface( - color = backgroundColor, - shadowElevation = 0.dp - ) { - Row( + // 🚀 Весь контент с fade-in анимацией + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = screenAlpha } + ) { + Scaffold( + topBar = { + // Кастомный TopAppBar для чата + Surface( + color = backgroundColor, + shadowElevation = 0.dp + ) { + Row( modifier = Modifier .fillMaxWidth() .statusBarsPadding() @@ -155,10 +253,7 @@ fun ChatDetailScreen( verticalAlignment = Alignment.CenterVertically ) { // Кнопка назад - IconButton(onClick = { - keyboardController?.hide() - onBack() - }) { + IconButton(onClick = hideKeyboardAndBack) { Icon( Icons.Default.ArrowBack, contentDescription = "Back", @@ -217,7 +312,12 @@ fun ChatDetailScreen( Text( text = chatSubtitle, fontSize = 13.sp, - color = if (!isSavedMessages && user.online == 1) PrimaryBlue else secondaryTextColor, + color = when { + isSavedMessages -> secondaryTextColor + isTyping -> PrimaryBlue // Синий когда печатает + isOnline -> Color(0xFF38B24D) // Зелёный когда онлайн + else -> secondaryTextColor // Серый для offline + }, maxLines = 1 ) } @@ -307,11 +407,24 @@ fun ChatDetailScreen( contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), reverseLayout = true ) { - items(messages.reversed(), key = { it.id }) { message -> - MessageBubble( - message = message, - isDarkTheme = isDarkTheme - ) + // Для inverted FlatList: идём от новых к старым + val reversedMessages = messagesWithDates.reversed() + itemsIndexed(reversedMessages, key = { _, item -> item.first.id }) { index, (message, showDate) -> + // В inverted списке дата показывается ПЕРЕД сообщением (визуально ПОСЛЕ) + Column { + MessageBubble( + message = message, + isDarkTheme = isDarkTheme, + index = index + ) + // Разделитель даты + if (showDate) { + DateHeader( + dateText = getDateText(message.timestamp.time), + secondaryTextColor = secondaryTextColor + ) + } + } } } } @@ -320,7 +433,13 @@ fun ChatDetailScreen( // Поле ввода сообщения MessageInputBar( value = inputText, - onValueChange = { viewModel.updateInputText(it) }, + onValueChange = { + viewModel.updateInputText(it) + // Отправляем индикатор печатания + if (it.isNotEmpty() && !isSavedMessages) { + viewModel.sendTypingIndicator() + } + }, onSend = { viewModel.sendMessage() ProtocolManager.addLog("📤 Sending message...") @@ -332,6 +451,7 @@ fun ChatDetailScreen( ) } } + } // Закрытие Box с fade-in // Диалог логов if (showLogs) { @@ -384,13 +504,38 @@ fun ChatDetailScreen( } /** - * Пузырек сообщения + * 🚀 Пузырек сообщения с fade-in анимацией */ @Composable private fun MessageBubble( message: ChatMessage, - isDarkTheme: Boolean + isDarkTheme: Boolean, + index: Int = 0 // Для staggered анимации ) { + // 🔥 Fade-in + slide анимация + var isVisible by remember { mutableStateOf(false) } + val alpha by animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = tween( + durationMillis = 150, + delayMillis = minOf(index * 20, 200), // Staggered, max 200ms delay + easing = FastOutSlowInEasing + ), + label = "bubbleAlpha" + ) + val offsetY by animateFloatAsState( + targetValue = if (isVisible) 0f else 20f, + animationSpec = tween( + durationMillis = 150, + delayMillis = minOf(index * 20, 200), + easing = FastOutSlowInEasing + ), + label = "bubbleOffset" + ) + + LaunchedEffect(message.id) { + isVisible = true + } val bubbleColor = if (message.isOutgoing) { PrimaryBlue } else { @@ -408,7 +553,11 @@ private fun MessageBubble( Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 2.dp), + .padding(vertical = 2.dp) + .graphicsLayer { + this.alpha = alpha + translationY = offsetY + }, horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start ) { Box( @@ -464,6 +613,48 @@ private fun MessageBubble( } } +/** + * 🚀 Разделитель даты с fade-in анимацией + */ +@Composable +private fun DateHeader( + dateText: String, + secondaryTextColor: Color +) { + // Fade-in анимация + var isVisible by remember { mutableStateOf(false) } + val alpha by animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + label = "dateAlpha" + ) + + LaunchedEffect(dateText) { + isVisible = true + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .graphicsLayer { this.alpha = alpha }, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = dateText, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = secondaryTextColor, + modifier = Modifier + .background( + color = secondaryTextColor.copy(alpha = 0.1f), + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) + } +} + /** * Панель ввода сообщения 1:1 как в React Native * Оптимизированная версия с правильным позиционированием diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 1411682..4dc668e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -63,6 +63,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val _opponentTyping = MutableStateFlow(false) val opponentTyping: StateFlow = _opponentTyping.asStateFlow() + // 🟢 Онлайн статус собеседника + private val _opponentOnline = MutableStateFlow(false) + val opponentOnline: StateFlow = _opponentOnline.asStateFlow() + + private val _opponentLastSeen = MutableStateFlow(0L) + val opponentLastSeen: StateFlow = _opponentLastSeen.asStateFlow() + // Input state private val _inputText = MutableStateFlow("") val inputText: StateFlow = _inputText.asStateFlow() @@ -78,6 +85,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Job для отмены загрузки при смене диалога private var loadingJob: Job? = null + // 🔥 Throttling для typing индикатора + private var lastTypingSentTime = 0L + private val TYPING_THROTTLE_MS = 2000L // Отправляем не чаще чем раз в 2 сек + + // Отслеживание прочитанных сообщений + private val sentReadReceipts = mutableSetOf() + init { setupPacketListeners() } @@ -96,8 +110,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Доставка ProtocolManager.waitPacket(0x08) { packet -> val deliveryPacket = packet as PacketDelivery - viewModelScope.launch { - updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED) + viewModelScope.launch(Dispatchers.IO) { + // Обновляем в БД + updateMessageStatusInDb(deliveryPacket.messageId, 1) // DELIVERED + // Обновляем UI + withContext(Dispatchers.Main) { + updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED) + } ProtocolManager.addLog("✓ Delivered: ${deliveryPacket.messageId.take(8)}...") } } @@ -105,8 +124,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Прочитано ProtocolManager.waitPacket(0x07) { packet -> val readPacket = packet as PacketRead - viewModelScope.launch { - updateMessageStatus(readPacket.messageId, MessageStatus.READ) + viewModelScope.launch(Dispatchers.IO) { + // Обновляем в БД + updateMessageStatusInDb(readPacket.messageId, 3) // READ + // Обновляем UI + withContext(Dispatchers.Main) { + updateMessageStatus(readPacket.messageId, MessageStatus.READ) + } ProtocolManager.addLog("✓✓ Read: ${readPacket.messageId.take(8)}...") } } @@ -118,6 +142,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { showTypingIndicator() } } + + // 🟢 Онлайн статус + ProtocolManager.waitPacket(0x05) { packet -> + val onlinePacket = packet as PacketOnlineState + if (onlinePacket.publicKey == opponentKey) { + viewModelScope.launch { + _opponentOnline.value = onlinePacket.online == 1 + _opponentLastSeen.value = onlinePacket.lastSeen + ProtocolManager.addLog("🟢 Online status: ${if (onlinePacket.online == 1) "online" else "offline"}") + } + } + } } private fun handleIncomingMessage(packet: PacketMessage) { @@ -186,6 +222,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * Обновить статус сообщения в БД + */ + private suspend fun updateMessageStatusInDb(messageId: String, delivered: Int) { + val account = myPublicKey ?: return + try { + messageDao.updateDeliveryStatus(account, messageId, delivered) + } catch (e: Exception) { + Log.e(TAG, "Update delivery status error", e) + } + } + /** * Установить ключи пользователя */ @@ -213,12 +261,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Сбрасываем состояние _messages.value = emptyList() + _opponentOnline.value = false + _opponentTyping.value = false currentOffset = 0 hasMoreMessages = true isLoadingMessages = false + sentReadReceipts.clear() ProtocolManager.addLog("💬 Dialog opened: ${title.ifEmpty { publicKey.take(16) }}...") + // Подписываемся на онлайн статус + subscribeToOnlineStatus() + // Загружаем сообщения из БД loadMessagesFromDatabase() } @@ -444,7 +498,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { encryptedContent = encryptedContent, encryptedKey = encryptedKey, timestamp = timestamp, - isFromMe = true + isFromMe = true, + delivered = 1 // SENT - сервер принял ) saveDialog(text, timestamp) @@ -543,10 +598,124 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * 📝 Отправить индикатор "печатает..." + * С throttling чтобы не спамить сервер + */ + fun sendTypingIndicator() { + val now = System.currentTimeMillis() + if (now - lastTypingSentTime < TYPING_THROTTLE_MS) return + + val opponent = opponentKey ?: return + val sender = myPublicKey ?: return + val privateKey = myPrivateKey ?: return + + lastTypingSentTime = now + + viewModelScope.launch(Dispatchers.IO) { + try { + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) + + val packet = PacketTyping().apply { + fromPublicKey = sender + toPublicKey = opponent + this.privateKey = privateKeyHash + } + + ProtocolManager.send(packet) + ProtocolManager.addLog("⌨️ Typing indicator sent") + } catch (e: Exception) { + Log.e(TAG, "Typing send error", e) + } + } + } + + /** + * 👁️ Отправить подтверждение о прочтении сообщения + */ + fun sendReadReceipt(messageId: String, senderPublicKey: String) { + // Не отправляем повторно + if (sentReadReceipts.contains(messageId)) return + + val sender = myPublicKey ?: return + val privateKey = myPrivateKey ?: return + + sentReadReceipts.add(messageId) + + viewModelScope.launch(Dispatchers.IO) { + try { + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) + + val packet = PacketRead().apply { + this.messageId = messageId + fromPublicKey = sender + toPublicKey = senderPublicKey + this.privateKey = privateKeyHash + } + + ProtocolManager.send(packet) + ProtocolManager.addLog("👁️ Read receipt sent for: ${messageId.take(8)}...") + + // Обновляем в БД что сообщение прочитано + updateMessageReadInDb(messageId) + } catch (e: Exception) { + Log.e(TAG, "Read receipt send error", e) + } + } + } + + /** + * 👁️ Отметить все непрочитанные входящие сообщения как прочитанные + */ + fun markVisibleMessagesAsRead() { + val opponent = opponentKey ?: return + + viewModelScope.launch { + _messages.value + .filter { !it.isOutgoing && it.status != MessageStatus.READ } + .forEach { message -> + sendReadReceipt(message.id, opponent) + } + } + } + + /** + * 🟢 Подписаться на онлайн статус собеседника + */ + fun subscribeToOnlineStatus() { + val opponent = opponentKey ?: return + + viewModelScope.launch(Dispatchers.IO) { + try { + val packet = PacketOnlineSubscribe().apply { + publicKey = opponent + } + + ProtocolManager.send(packet) + ProtocolManager.addLog("🟢 Subscribed to online status: ${opponent.take(16)}...") + } catch (e: Exception) { + Log.e(TAG, "Online subscribe error", e) + } + } + } + + /** + * Обновить статус прочтения в БД + */ + private suspend fun updateMessageReadInDb(messageId: String) { + try { + val account = myPublicKey ?: return + messageDao.markAsRead(account, messageId) + } catch (e: Exception) { + Log.e(TAG, "Update read status error", e) + } + } + fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending override fun onCleared() { super.onCleared() + sentReadReceipts.clear() opponentKey = null } }