feat: Enhance chat functionality by updating dialog handling and adding ChatsListViewModel for database integration
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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("<EFBFBD> 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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<DialogEntity>>(emptyList())
|
||||
val dialogs: StateFlow<List<DialogEntity>> = _dialogs.asStateFlow()
|
||||
|
||||
// Загрузка
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user