feat: Enhance ChatDetailScreen and ChatViewModel with dynamic online status and typing indicators

This commit is contained in:
k1ngsterr1
2026-01-11 04:58:25 +05:00
parent 8f420f3d70
commit 30ad6d1cc1
2 changed files with 389 additions and 29 deletions

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.chats package com.rosetta.messenger.ui.chats
import androidx.activity.compose.BackHandler
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -8,6 +9,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -63,13 +65,33 @@ data class ChatMessage(
val text: String, val text: String,
val isOutgoing: Boolean, val isOutgoing: Boolean,
val timestamp: Date, val timestamp: Date,
val status: MessageStatus = MessageStatus.SENT val status: MessageStatus = MessageStatus.SENT,
val showDateHeader: Boolean = false // Показывать ли разделитель даты
) )
enum class MessageStatus { enum class MessageStatus {
SENDING, SENT, DELIVERED, READ 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 для конвертации // Extension для конвертации
private fun Message.toChatMessage() = ChatMessage( private fun Message.toChatMessage() = ChatMessage(
id = messageId, id = messageId,
@@ -98,15 +120,38 @@ fun ChatDetailScreen(
viewModel: ChatViewModel = viewModel() viewModel: ChatViewModel = viewModel()
) { ) {
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0) val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
// <20> 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 или обычный чат // Определяем это Saved Messages или обычный чат
val isSavedMessages = user.publicKey == currentUserPublicKey val isSavedMessages = user.publicKey == currentUserPublicKey
val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) } 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) } var showLogs by remember { mutableStateOf(false) }
@@ -116,16 +161,63 @@ fun ChatDetailScreen(
val messages by viewModel.messages.collectAsState() val messages by viewModel.messages.collectAsState()
val inputText by viewModel.inputText.collectAsState() val inputText by viewModel.inputText.collectAsState()
val isTyping by viewModel.opponentTyping.collectAsState() val isTyping by viewModel.opponentTyping.collectAsState()
val isOnline by viewModel.opponentOnline.collectAsState()
// 🔥 Добавляем информацию о датах к сообщениям
val messagesWithDates = remember(messages) {
val result = mutableListOf<Pair<ChatMessage, Boolean>>() // 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 listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// 🔥 Обработка системной кнопки назад
BackHandler {
hideKeyboardAndBack()
}
// 🔥 Cleanup при выходе из экрана
DisposableEffect(Unit) {
onDispose {
focusManager.clearFocus()
keyboardController?.hide()
}
}
// Инициализируем ViewModel с ключами и открываем диалог // Инициализируем ViewModel с ключами и открываем диалог
LaunchedEffect(user.publicKey) { LaunchedEffect(user.publicKey) {
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey) viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
viewModel.openDialog(user.publicKey, user.title, user.username) viewModel.openDialog(user.publicKey, user.title, user.username)
} }
// Отмечаем сообщения как прочитанные когда они видны
LaunchedEffect(messages) {
if (messages.isNotEmpty()) {
viewModel.markVisibleMessagesAsRead()
}
}
// Прокрутка при новых сообщениях // Прокрутка при новых сообщениях
LaunchedEffect(messages.size) { LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
@@ -139,6 +231,12 @@ fun ChatDetailScreen(
isDarkTheme isDarkTheme
) )
// 🚀 Весь контент с fade-in анимацией
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = screenAlpha }
) {
Scaffold( Scaffold(
topBar = { topBar = {
// Кастомный TopAppBar для чата // Кастомный TopAppBar для чата
@@ -155,10 +253,7 @@ fun ChatDetailScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Кнопка назад // Кнопка назад
IconButton(onClick = { IconButton(onClick = hideKeyboardAndBack) {
keyboardController?.hide()
onBack()
}) {
Icon( Icon(
Icons.Default.ArrowBack, Icons.Default.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
@@ -217,7 +312,12 @@ fun ChatDetailScreen(
Text( Text(
text = chatSubtitle, text = chatSubtitle,
fontSize = 13.sp, 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 maxLines = 1
) )
} }
@@ -307,11 +407,24 @@ fun ChatDetailScreen(
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
reverseLayout = true reverseLayout = true
) { ) {
items(messages.reversed(), key = { it.id }) { message -> // Для inverted FlatList: идём от новых к старым
val reversedMessages = messagesWithDates.reversed()
itemsIndexed(reversedMessages, key = { _, item -> item.first.id }) { index, (message, showDate) ->
// В inverted списке дата показывается ПЕРЕД сообщением (визуально ПОСЛЕ)
Column {
MessageBubble( MessageBubble(
message = message, message = message,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
index = index
) )
// Разделитель даты
if (showDate) {
DateHeader(
dateText = getDateText(message.timestamp.time),
secondaryTextColor = secondaryTextColor
)
}
}
} }
} }
} }
@@ -320,7 +433,13 @@ fun ChatDetailScreen(
// Поле ввода сообщения // Поле ввода сообщения
MessageInputBar( MessageInputBar(
value = inputText, value = inputText,
onValueChange = { viewModel.updateInputText(it) }, onValueChange = {
viewModel.updateInputText(it)
// Отправляем индикатор печатания
if (it.isNotEmpty() && !isSavedMessages) {
viewModel.sendTypingIndicator()
}
},
onSend = { onSend = {
viewModel.sendMessage() viewModel.sendMessage()
ProtocolManager.addLog("📤 Sending message...") ProtocolManager.addLog("📤 Sending message...")
@@ -332,6 +451,7 @@ fun ChatDetailScreen(
) )
} }
} }
} // Закрытие Box с fade-in
// Диалог логов // Диалог логов
if (showLogs) { if (showLogs) {
@@ -384,13 +504,38 @@ fun ChatDetailScreen(
} }
/** /**
* Пузырек сообщения * 🚀 Пузырек сообщения с fade-in анимацией
*/ */
@Composable @Composable
private fun MessageBubble( private fun MessageBubble(
message: ChatMessage, 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) { val bubbleColor = if (message.isOutgoing) {
PrimaryBlue PrimaryBlue
} else { } else {
@@ -408,7 +553,11 @@ private fun MessageBubble(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 2.dp), .padding(vertical = 2.dp)
.graphicsLayer {
this.alpha = alpha
translationY = offsetY
},
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
) { ) {
Box( 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 * Панель ввода сообщения 1:1 как в React Native
* Оптимизированная версия с правильным позиционированием * Оптимизированная версия с правильным позиционированием

View File

@@ -63,6 +63,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _opponentTyping = MutableStateFlow(false) private val _opponentTyping = MutableStateFlow(false)
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow() val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
// 🟢 Онлайн статус собеседника
private val _opponentOnline = MutableStateFlow(false)
val opponentOnline: StateFlow<Boolean> = _opponentOnline.asStateFlow()
private val _opponentLastSeen = MutableStateFlow(0L)
val opponentLastSeen: StateFlow<Long> = _opponentLastSeen.asStateFlow()
// Input state // Input state
private val _inputText = MutableStateFlow("") private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow() val inputText: StateFlow<String> = _inputText.asStateFlow()
@@ -78,6 +85,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Job для отмены загрузки при смене диалога // Job для отмены загрузки при смене диалога
private var loadingJob: Job? = null private var loadingJob: Job? = null
// 🔥 Throttling для typing индикатора
private var lastTypingSentTime = 0L
private val TYPING_THROTTLE_MS = 2000L // Отправляем не чаще чем раз в 2 сек
// Отслеживание прочитанных сообщений
private val sentReadReceipts = mutableSetOf<String>()
init { init {
setupPacketListeners() setupPacketListeners()
} }
@@ -96,8 +110,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Доставка // Доставка
ProtocolManager.waitPacket(0x08) { packet -> ProtocolManager.waitPacket(0x08) { packet ->
val deliveryPacket = packet as PacketDelivery val deliveryPacket = packet as PacketDelivery
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
// Обновляем в БД
updateMessageStatusInDb(deliveryPacket.messageId, 1) // DELIVERED
// Обновляем UI
withContext(Dispatchers.Main) {
updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED) updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED)
}
ProtocolManager.addLog("✓ Delivered: ${deliveryPacket.messageId.take(8)}...") ProtocolManager.addLog("✓ Delivered: ${deliveryPacket.messageId.take(8)}...")
} }
} }
@@ -105,8 +124,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Прочитано // Прочитано
ProtocolManager.waitPacket(0x07) { packet -> ProtocolManager.waitPacket(0x07) { packet ->
val readPacket = packet as PacketRead val readPacket = packet as PacketRead
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
// Обновляем в БД
updateMessageStatusInDb(readPacket.messageId, 3) // READ
// Обновляем UI
withContext(Dispatchers.Main) {
updateMessageStatus(readPacket.messageId, MessageStatus.READ) updateMessageStatus(readPacket.messageId, MessageStatus.READ)
}
ProtocolManager.addLog("✓✓ Read: ${readPacket.messageId.take(8)}...") ProtocolManager.addLog("✓✓ Read: ${readPacket.messageId.take(8)}...")
} }
} }
@@ -118,6 +142,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
showTypingIndicator() 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) { 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() _messages.value = emptyList()
_opponentOnline.value = false
_opponentTyping.value = false
currentOffset = 0 currentOffset = 0
hasMoreMessages = true hasMoreMessages = true
isLoadingMessages = false isLoadingMessages = false
sentReadReceipts.clear()
ProtocolManager.addLog("💬 Dialog opened: ${title.ifEmpty { publicKey.take(16) }}...") ProtocolManager.addLog("💬 Dialog opened: ${title.ifEmpty { publicKey.take(16) }}...")
// Подписываемся на онлайн статус
subscribeToOnlineStatus()
// Загружаем сообщения из БД // Загружаем сообщения из БД
loadMessagesFromDatabase() loadMessagesFromDatabase()
} }
@@ -444,7 +498,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedContent = encryptedContent, encryptedContent = encryptedContent,
encryptedKey = encryptedKey, encryptedKey = encryptedKey,
timestamp = timestamp, timestamp = timestamp,
isFromMe = true isFromMe = true,
delivered = 1 // SENT - сервер принял
) )
saveDialog(text, timestamp) 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 fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
sentReadReceipts.clear()
opponentKey = null opponentKey = null
} }
} }