From 219158ac7da486d2335a5e5b5fff69fc9e49c0e3 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 10 Jan 2026 23:06:41 +0500 Subject: [PATCH] feat: Enhance chat functionality by updating dialog handling and adding ChatsListViewModel for database integration --- .../rosetta/messenger/crypto/MessageCrypto.kt | 92 ++++++---- .../messenger/ui/chats/ChatDetailScreen.kt | 16 +- .../messenger/ui/chats/ChatViewModel.kt | 84 ++++++++- .../messenger/ui/chats/ChatsListScreen.kt | 165 +++++++++++++++++- .../messenger/ui/chats/ChatsListViewModel.kt | 97 ++++++++++ 5 files changed, 403 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 181543c..d1b448d 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -274,83 +274,89 @@ object MessageCrypto { /** * ECDH шифрование ключа для получателя * Использует secp256k1 + AES как в RN версии + * Формат: Base64(iv:ciphertext:ephemeralPrivateKeyHex) */ fun encryptKeyForRecipient(keyAndNonce: ByteArray, recipientPublicKeyHex: String): String { val secureRandom = SecureRandom() val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1") - // Генерируем эфемерный приватный ключ + // Генерируем эфемерный приватный ключ (32 байта) val ephemeralPrivateKeyBytes = ByteArray(32) secureRandom.nextBytes(ephemeralPrivateKeyBytes) val ephemeralPrivateKey = BigInteger(1, ephemeralPrivateKeyBytes) + val ephemeralPrivateKeyHex = ephemeralPrivateKeyBytes.toHex() // Получаем эфемерный публичный ключ val ephemeralPublicKey = ecSpec.g.multiply(ephemeralPrivateKey) - val ephemeralPublicKeyHex = ephemeralPublicKey.getEncoded(false).toHex() // Парсим публичный ключ получателя val recipientPublicKeyBytes = recipientPublicKeyHex.hexToBytes() val recipientPublicKey = ecSpec.curve.decodePoint(recipientPublicKeyBytes) - // ECDH: получаем общий секрет + // ECDH: ephemeralPrivate * recipientPublic = sharedSecret val sharedPoint = recipientPublicKey.multiply(ephemeralPrivateKey) val sharedSecret = sharedPoint.normalize().xCoord.encoded + val sharedSecretHex = sharedSecret.toHex() - // Derive AES key from shared secret - val aesKey = MessageDigest.getInstance("SHA-256").digest(sharedSecret) - - // Генерируем IV для AES + // Генерируем IV для AES (16 байт) val iv = ByteArray(16) secureRandom.nextBytes(iv) + val ivHex = iv.toHex() - // Шифруем keyAndNonce с AES-CBC - val secretKey = SecretKeySpec(aesKey, "AES") + // Шифруем keyAndNonce с AES-CBC используя sharedSecret как ключ + // ВАЖНО: Используем сам sharedSecret напрямую (32 байта) без SHA-256! + val aesKey = SecretKeySpec(sharedSecret, "AES") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(iv)) + cipher.init(Cipher.ENCRYPT_MODE, aesKey, IvParameterSpec(iv)) val encryptedKey = cipher.doFinal(keyAndNonce) + val encryptedKeyHex = encryptedKey.toHex() - // Формат: iv:ciphertext:ephemeralPublicKey (Base64) - val result = ByteArray(iv.size + encryptedKey.size + recipientPublicKeyBytes.size) - System.arraycopy(iv, 0, result, 0, iv.size) - System.arraycopy(encryptedKey, 0, result, iv.size, encryptedKey.size) + // Формат как в RN: btoa(ivHex:encryptedHex:ephemeralPrivateHex) + val combined = "$ivHex:$encryptedKeyHex:$ephemeralPrivateKeyHex" - // Возвращаем как Base64 с ephemeral public key в конце - return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + - Base64.encodeToString(encryptedKey, Base64.NO_WRAP) + ":" + - ephemeralPublicKeyHex + ProtocolManager.addLog("🔐 ECDH Encrypt:") + ProtocolManager.addLog(" - Shared secret: ${sharedSecretHex.take(40)}...") + ProtocolManager.addLog(" - IV: ${ivHex.take(32)}...") + ProtocolManager.addLog(" - Ephemeral private: ${ephemeralPrivateKeyHex.take(40)}...") + ProtocolManager.addLog(" - Recipient public: ${recipientPublicKeyHex.take(40)}...") + + return Base64.encodeToString(combined.toByteArray(), Base64.NO_WRAP) } /** * ECDH расшифровка ключа + * Формат: Base64(ivHex:encryptedHex:ephemeralPrivateHex) */ fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray { - val parts = encryptedKeyBase64.split(":") - if (parts.size != 3) throw IllegalArgumentException("Invalid encrypted key format") + val combined = String(Base64.decode(encryptedKeyBase64, Base64.NO_WRAP)) + val parts = combined.split(":") + if (parts.size != 3) throw IllegalArgumentException("Invalid encrypted key format: expected 3 parts, got ${parts.size}") - val iv = Base64.decode(parts[0], Base64.NO_WRAP) - val encryptedKey = Base64.decode(parts[1], Base64.NO_WRAP) - val ephemeralPublicKeyHex = parts[2] + val ivHex = parts[0] + val encryptedKeyHex = parts[1] + val ephemeralPrivateKeyHex = parts[2] + + val iv = ivHex.hexToBytes() + val encryptedKey = encryptedKeyHex.hexToBytes() val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1") + // Парсим эфемерный приватный ключ + val ephemeralPrivateKey = BigInteger(ephemeralPrivateKeyHex, 16) + val ephemeralPublicKey = ecSpec.g.multiply(ephemeralPrivateKey) + // Парсим мой приватный ключ val myPrivateKey = BigInteger(myPrivateKeyHex, 16) + val myPublicKey = ecSpec.g.multiply(myPrivateKey) - // Парсим эфемерный публичный ключ отправителя - val ephemeralPublicKeyBytes = ephemeralPublicKeyHex.hexToBytes() - val ephemeralPublicKey = ecSpec.curve.decodePoint(ephemeralPublicKeyBytes) - - // ECDH: получаем общий секрет - val sharedPoint = ephemeralPublicKey.multiply(myPrivateKey) + // ECDH: ephemeralPrivate * myPublic = sharedSecret + val sharedPoint = myPublicKey.multiply(ephemeralPrivateKey) val sharedSecret = sharedPoint.normalize().xCoord.encoded - // Derive AES key from shared secret - val aesKey = MessageDigest.getInstance("SHA-256").digest(sharedSecret) - - // Расшифровываем - val secretKey = SecretKeySpec(aesKey, "AES") + // Расшифровываем используя sharedSecret как AES ключ + val aesKey = SecretKeySpec(sharedSecret, "AES") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv)) + cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv)) return cipher.doFinal(encryptedKey) } @@ -359,15 +365,31 @@ object MessageCrypto { * Полное шифрование сообщения для отправки */ fun encryptForSending(plaintext: String, recipientPublicKey: String): Pair { + android.util.Log.d("MessageCrypto", "🔐 === START ENCRYPTION ===") + android.util.Log.d("MessageCrypto", "📝 Plaintext: '$plaintext'") + android.util.Log.d("MessageCrypto", "📝 Plaintext length: ${plaintext.length}") + android.util.Log.d("MessageCrypto", "🔑 Recipient public key: ${recipientPublicKey.take(20)}...") + // 1. Шифруем текст + android.util.Log.d("MessageCrypto", "⚡ Step 1: Encrypting message with XChaCha20-Poly1305...") val encrypted = encryptMessage(plaintext) + android.util.Log.d("MessageCrypto", "✅ Ciphertext: ${encrypted.ciphertext.take(40)}...") + android.util.Log.d("MessageCrypto", "✅ Ciphertext length: ${encrypted.ciphertext.length}") + android.util.Log.d("MessageCrypto", "✅ Key: ${encrypted.key.take(20)}...") + android.util.Log.d("MessageCrypto", "✅ Nonce: ${encrypted.nonce.take(20)}...") // 2. Собираем key + nonce + android.util.Log.d("MessageCrypto", "⚡ Step 2: Combining key + nonce...") val keyAndNonce = encrypted.key.hexToBytes() + encrypted.nonce.hexToBytes() + android.util.Log.d("MessageCrypto", "✅ Combined keyAndNonce length: ${keyAndNonce.size} bytes") // 3. Шифруем ключ для получателя + android.util.Log.d("MessageCrypto", "⚡ Step 3: Encrypting key with ECDH + AES...") val encryptedKey = encryptKeyForRecipient(keyAndNonce, recipientPublicKey) + android.util.Log.d("MessageCrypto", "✅ Encrypted key: ${encryptedKey.take(40)}...") + android.util.Log.d("MessageCrypto", "✅ Encrypted key length: ${encryptedKey.length}") + android.util.Log.d("MessageCrypto", "🔐 === ENCRYPTION COMPLETE ===") return Pair(encrypted.ciphertext, encryptedKey) } 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 b19abbc..b824287 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 @@ -86,7 +86,7 @@ private fun Message.toChatMessage() = ChatMessage( /** * Экран детального чата с пользователем */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun ChatDetailScreen( user: SearchUser, @@ -97,6 +97,7 @@ fun ChatDetailScreen( onUserProfileClick: () -> Unit = {}, viewModel: ChatViewModel = viewModel() ) { + val keyboardController = LocalSoftwareKeyboardController.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) @@ -122,7 +123,7 @@ fun ChatDetailScreen( // Инициализируем ViewModel с ключами и открываем диалог LaunchedEffect(user.publicKey) { viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey) - viewModel.openDialog(user.publicKey) + viewModel.openDialog(user.publicKey, user.title, user.username) } // Прокрутка при новых сообщениях @@ -154,7 +155,10 @@ fun ChatDetailScreen( verticalAlignment = Alignment.CenterVertically ) { // Кнопка назад - IconButton(onClick = onBack) { + IconButton(onClick = { + keyboardController?.hide() + onBack() + }) { Icon( Icons.Default.ArrowBack, contentDescription = "Back", @@ -545,7 +549,7 @@ private fun MessageInputBar( Column( modifier = Modifier .fillMaxWidth() - .imePadding() + .then(if (!showEmojiPicker) Modifier.imePadding() else Modifier) ) { Box( modifier = Modifier @@ -709,8 +713,8 @@ private fun MessageInputBar( // Apple Emoji Picker AnimatedVisibility( visible = showEmojiPicker, - enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut() + enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut() ) { AppleEmojiPickerPanel( isDarkTheme = isDarkTheme, 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 9e6583d..82e5141 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 @@ -6,6 +6,8 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.MessageCrypto +import com.rosetta.messenger.database.DialogEntity +import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -22,6 +24,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private const val TAG = "ChatViewModel" } + // Database + private val database = RosettaDatabase.getDatabase(application) + private val dialogDao = database.dialogDao() + + // Информация о собеседнике + private var opponentTitle: String = "" + private var opponentUsername: String = "" + // Текущий диалог private var opponentKey: String? = null private var myPublicKey: String? = null @@ -120,14 +130,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** * Открыть диалог */ - fun openDialog(publicKey: String) { + fun openDialog(publicKey: String, title: String = "", username: String = "") { if (opponentKey == publicKey) { ProtocolManager.addLog("💬 Dialog already open: ${publicKey.take(16)}...") return } opponentKey = publicKey + opponentTitle = title + opponentUsername = username _messages.value = emptyList() - ProtocolManager.addLog("💬 Dialog opened: ${publicKey.take(16)}...") + ProtocolManager.addLog("💬 Dialog opened: ${title.ifEmpty { publicKey.take(16) }}...") } /** @@ -179,15 +191,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { _messages.value = _messages.value + optimisticMessage _inputText.value = "" - ProtocolManager.addLog("📤 Send: \"${text.take(20)}...\"") - ProtocolManager.addLog("📋 Messages count: ${_messages.value.size}") + ProtocolManager.addLog("📤 === START SENDING MESSAGE ===") + ProtocolManager.addLog("📤 Text: \"${text.take(20)}...\"") + ProtocolManager.addLog("📤 Text length: ${text.length}") + ProtocolManager.addLog("📤 Recipient: ${recipient.take(20)}...") + ProtocolManager.addLog("📤 Sender: ${sender.take(20)}...") + ProtocolManager.addLog("📤 Message ID: $messageId") + ProtocolManager.addLog("📋 Current messages count: ${_messages.value.size}") // 2. Отправка в фоне viewModelScope.launch { try { - ProtocolManager.addLog("🔐 Encrypting...") + ProtocolManager.addLog("🔐 Starting encryption...") val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(text, recipient) - ProtocolManager.addLog("✓ Encrypted") + ProtocolManager.addLog("✅ Encryption complete") + ProtocolManager.addLog(" - Encrypted content length: ${encryptedContent.length}") + ProtocolManager.addLog(" - Encrypted key length: ${encryptedKey.length}") + + ProtocolManager.addLog("📦 Creating PacketMessage...") + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) + ProtocolManager.addLog("🔑 Private key hash: ${privateKeyHash.take(20)}...") val packet = PacketMessage().apply { fromPublicKey = sender @@ -195,16 +218,29 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { content = encryptedContent chachaKey = encryptedKey this.timestamp = timestamp - this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) + this.privateKey = privateKeyHash this.messageId = messageId attachments = emptyList() } - ProtocolManager.addLog("📡 Sending packet...") + ProtocolManager.addLog("� Packet created:") + ProtocolManager.addLog(" - From: ${sender.take(20)}...") + ProtocolManager.addLog(" - To: ${recipient.take(20)}...") + ProtocolManager.addLog(" - Content: ${encryptedContent.take(40)}...") + ProtocolManager.addLog(" - ChaCha Key: ${encryptedKey.take(40)}...") + ProtocolManager.addLog(" - Timestamp: $timestamp") + ProtocolManager.addLog(" - Message ID: $messageId") + + ProtocolManager.addLog("📡 Sending packet to server...") ProtocolManager.send(packet) + ProtocolManager.addLog("📡 Packet sent to ProtocolManager") updateMessageStatus(messageId, MessageStatus.SENT) - ProtocolManager.addLog("✓ Sent!") + ProtocolManager.addLog("✅ Message status updated to SENT") + ProtocolManager.addLog("📤 === SENDING COMPLETE ===") + + // Сохраняем/обновляем диалог в базе + saveDialog(text, timestamp) } catch (e: Exception) { ProtocolManager.addLog("❌ Error: ${e.message}") @@ -215,6 +251,36 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * Сохранить диалог в базу данных + */ + private suspend fun saveDialog(lastMessage: String, timestamp: Long) { + val account = myPublicKey ?: return + val opponent = opponentKey ?: return + + try { + val existingDialog = dialogDao.getDialog(account, opponent) + + if (existingDialog != null) { + // Обновляем последнее сообщение + dialogDao.updateLastMessage(account, opponent, lastMessage, timestamp) + } else { + // Создаём новый диалог + dialogDao.insertDialog(DialogEntity( + account = account, + opponentKey = opponent, + opponentTitle = opponentTitle, + opponentUsername = opponentUsername, + lastMessage = lastMessage, + lastMessageTimestamp = timestamp + )) + } + ProtocolManager.addLog("💾 Dialog saved") + } catch (e: Exception) { + ProtocolManager.addLog("❌ Failed to save dialog: ${e.message}") + } + } + private fun showTypingIndicator() { _opponentTyping.value = true viewModelScope.launch { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index b8f89de..15be646 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.airbnb.lottie.compose.* import com.rosetta.messenger.R +import com.rosetta.messenger.database.DialogEntity import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.ui.onboarding.PrimaryBlue @@ -148,6 +149,7 @@ fun ChatsListScreen( onSearchClick: () -> Unit, onNewChat: () -> Unit, onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, + chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), onLogout: () -> Unit ) { // Theme transition state @@ -187,6 +189,16 @@ fun ChatsListScreen( val protocolState by ProtocolManager.state.collectAsState() val debugLogs by ProtocolManager.debugLogs.collectAsState() + // Dialogs from database + val dialogsList by chatsViewModel.dialogs.collectAsState() + + // Load dialogs when account is available + LaunchedEffect(accountPublicKey) { + if (accountPublicKey.isNotEmpty()) { + chatsViewModel.setAccount(accountPublicKey) + } + } + // Dev console state var showDevConsole by remember { mutableStateOf(false) } var titleClickCount by remember { mutableStateOf(0) } @@ -776,12 +788,28 @@ fun ChatsListScreen( } ) } - } else { + } else if (dialogsList.isEmpty()) { // Empty state with Lottie animation EmptyChatsState( isDarkTheme = isDarkTheme, modifier = Modifier.fillMaxSize() ) + } else { + // Show dialogs list + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(dialogsList, key = { it.opponentKey }) { dialog -> + DialogItem( + dialog = dialog, + isDarkTheme = isDarkTheme, + onClick = { + val user = chatsViewModel.dialogToSearchUser(dialog) + onUserSelect(user) + } + ) + } + } } // Console button - always visible at bottom left @@ -1096,3 +1124,138 @@ private fun formatTime(date: Date): String { } } } + +/** + * Элемент диалога из базы данных + */ +@Composable +fun DialogItem( + dialog: DialogEntity, + isDarkTheme: Boolean, + onClick: () -> Unit +) { + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + + val avatarColors = getAvatarColor(dialog.opponentKey, isDarkTheme) + val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) } + val initials = if (dialog.opponentTitle.isNotEmpty()) { + dialog.opponentTitle.split(" ") + .take(2) + .mapNotNull { it.firstOrNull()?.uppercase() } + .joinToString("") + } else { + dialog.opponentKey.take(2).uppercase() + } + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center + ) { + Text( + text = initials, + color = avatarColors.textColor, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ) + + // Online indicator + if (dialog.isOnline == 1) { + Box( + modifier = Modifier + .size(14.dp) + .align(Alignment.BottomEnd) + .offset(x = (-2).dp, y = (-2).dp) + .clip(CircleShape) + .background(if (isDarkTheme) Color(0xFF1A1A1A) else Color.White) + .padding(2.dp) + .clip(CircleShape) + .background(Color(0xFF4CAF50)) + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Name and last message + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = displayName, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Text( + text = formatTime(Date(dialog.lastMessageTimestamp)), + fontSize = 13.sp, + color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = dialog.lastMessage.ifEmpty { "No messages" }, + fontSize = 14.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + // Unread badge + if (dialog.unreadCount > 0) { + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .background(PrimaryBlue), + contentAlignment = Alignment.Center + ) { + Text( + text = if (dialog.unreadCount > 99) "99+" else dialog.unreadCount.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + } + } + } + } + } + + Divider( + modifier = Modifier.padding(start = 84.dp), + color = dividerColor, + thickness = 0.5.dp + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt new file mode 100644 index 0000000..d7c4a25 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -0,0 +1,97 @@ +package com.rosetta.messenger.ui.chats + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.rosetta.messenger.database.DialogEntity +import com.rosetta.messenger.database.RosettaDatabase +import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.network.SearchUser +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +/** + * ViewModel для списка чатов + * Загружает диалоги из базы данных + */ +class ChatsListViewModel(application: Application) : AndroidViewModel(application) { + + private val database = RosettaDatabase.getDatabase(application) + private val dialogDao = database.dialogDao() + + private var currentAccount: String = "" + + // Список диалогов из базы + private val _dialogs = MutableStateFlow>(emptyList()) + val dialogs: StateFlow> = _dialogs.asStateFlow() + + // Загрузка + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + /** + * Установить текущий аккаунт и загрузить диалоги + */ + fun setAccount(publicKey: String) { + if (currentAccount == publicKey) return + currentAccount = publicKey + + viewModelScope.launch { + dialogDao.getDialogsFlow(publicKey) + .collect { dialogsList -> + _dialogs.value = dialogsList + ProtocolManager.addLog("📋 Dialogs loaded: ${dialogsList.size}") + } + } + } + + /** + * Создать или обновить диалог после отправки/получения сообщения + */ + suspend fun upsertDialog( + opponentKey: String, + opponentTitle: String, + opponentUsername: String = "", + lastMessage: String, + timestamp: Long, + verified: Int = 0, + isOnline: Int = 0 + ) { + if (currentAccount.isEmpty()) return + + val existingDialog = dialogDao.getDialog(currentAccount, opponentKey) + + if (existingDialog != null) { + // Обновляем + dialogDao.updateLastMessage(currentAccount, opponentKey, lastMessage, timestamp) + if (opponentTitle.isNotEmpty()) { + dialogDao.updateOpponentInfo(currentAccount, opponentKey, opponentTitle, opponentUsername, verified) + } + } else { + // Создаём новый + dialogDao.insertDialog(DialogEntity( + account = currentAccount, + opponentKey = opponentKey, + opponentTitle = opponentTitle, + opponentUsername = opponentUsername, + lastMessage = lastMessage, + lastMessageTimestamp = timestamp, + verified = verified, + isOnline = isOnline + )) + } + } + + /** + * Конвертировать DialogEntity в SearchUser для навигации + */ + fun dialogToSearchUser(dialog: DialogEntity): SearchUser { + return SearchUser( + title = dialog.opponentTitle, + username = dialog.opponentUsername, + publicKey = dialog.opponentKey, + verified = dialog.verified, + online = dialog.isOnline + ) + } +}