feat: Update RosettaDatabase to include Message and Dialog entities, increment version to 2
feat: Implement message packets for sending and receiving messages, including delivery and read notifications feat: Enhance ProtocolManager to handle message sending, delivery, and typing status with appropriate logging feat: Refactor ChatDetailScreen to utilize ChatViewModel for managing chat state and message input feat: Create ChatViewModel to manage chat messages, input state, and packet listeners for incoming messages build: Add KSP plugin for annotation processing and configure Java 17 for the build environment
This commit is contained in:
@@ -4,6 +4,7 @@ import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -22,6 +23,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -37,18 +39,23 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rosetta.messenger.data.Message
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.network.DeliveryStatus
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Модель сообщения
|
||||
* Модель сообщения (Legacy - для совместимости)
|
||||
*/
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
@@ -62,6 +69,19 @@ enum class MessageStatus {
|
||||
SENDING, SENT, DELIVERED, READ
|
||||
}
|
||||
|
||||
// Extension для конвертации
|
||||
private fun Message.toChatMessage() = ChatMessage(
|
||||
id = messageId,
|
||||
text = content,
|
||||
isOutgoing = isFromMe,
|
||||
timestamp = Date(timestamp),
|
||||
status = when (deliveryStatus) {
|
||||
DeliveryStatus.WAITING -> MessageStatus.SENDING
|
||||
DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED
|
||||
DeliveryStatus.ERROR -> MessageStatus.SENT
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Экран детального чата с пользователем
|
||||
*/
|
||||
@@ -70,12 +90,13 @@ enum class MessageStatus {
|
||||
fun ChatDetailScreen(
|
||||
user: SearchUser,
|
||||
currentUserPublicKey: String,
|
||||
currentUserPrivateKey: String,
|
||||
isDarkTheme: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onUserProfileClick: () -> Unit = {}
|
||||
onUserProfileClick: () -> Unit = {},
|
||||
viewModel: ChatViewModel = viewModel()
|
||||
) {
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||
val surfaceColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
||||
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)
|
||||
@@ -85,28 +106,37 @@ fun ChatDetailScreen(
|
||||
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 messages by remember { mutableStateOf<List<ChatMessage>>(emptyList()) }
|
||||
var inputText by remember { mutableStateOf("") }
|
||||
// Состояние показа логов
|
||||
var showLogs by remember { mutableStateOf(false) }
|
||||
val debugLogs by ProtocolManager.debugLogs.collectAsState()
|
||||
|
||||
// Подключаем к ViewModel
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val isTyping by viewModel.opponentTyping.collectAsState()
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Инициализируем ViewModel с ключами и открываем диалог
|
||||
LaunchedEffect(user.publicKey) {
|
||||
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
||||
viewModel.openDialog(user.publicKey)
|
||||
}
|
||||
|
||||
// Прокрутка при новых сообщениях
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Аватар
|
||||
val avatarColors = getAvatarColor(
|
||||
if (isSavedMessages) "SavedMessages" else user.title.ifEmpty { user.publicKey },
|
||||
isDarkTheme
|
||||
)
|
||||
|
||||
// Анимация появления
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { visible = true }
|
||||
|
||||
// Логируем открытие чата
|
||||
LaunchedEffect(user.publicKey) {
|
||||
ProtocolManager.addLog("💬 Chat opened with: ${user.title.ifEmpty { user.publicKey.take(10) }}")
|
||||
ProtocolManager.addLog(" PublicKey: ${user.publicKey.take(20)}...")
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
// Кастомный TopAppBar для чата
|
||||
@@ -198,6 +228,15 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка логов (для отладки)
|
||||
IconButton(onClick = { showLogs = true }) {
|
||||
Icon(
|
||||
Icons.Default.BugReport,
|
||||
contentDescription = "Logs",
|
||||
tint = if (debugLogs.isNotEmpty()) PrimaryBlue else textColor
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { /* TODO: More options */ }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
@@ -276,30 +315,10 @@ fun ChatDetailScreen(
|
||||
// Поле ввода сообщения
|
||||
MessageInputBar(
|
||||
value = inputText,
|
||||
onValueChange = { inputText = it },
|
||||
onValueChange = { viewModel.updateInputText(it) },
|
||||
onSend = {
|
||||
if (inputText.isNotBlank()) {
|
||||
val newMessage = ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
text = inputText.trim(),
|
||||
isOutgoing = true,
|
||||
timestamp = Date(),
|
||||
status = MessageStatus.SENDING
|
||||
)
|
||||
messages = messages + newMessage
|
||||
|
||||
// Логируем отправку
|
||||
ProtocolManager.addLog("📤 Message sent: \"${inputText.take(30)}...\"")
|
||||
|
||||
inputText = ""
|
||||
|
||||
// Прокрутка вниз
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
|
||||
// TODO: Отправить через протокол
|
||||
}
|
||||
viewModel.sendMessage()
|
||||
ProtocolManager.addLog("📤 Sending message...")
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
backgroundColor = inputBackgroundColor,
|
||||
@@ -308,6 +327,55 @@ fun ChatDetailScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог логов
|
||||
if (showLogs) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLogs = false },
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Debug Logs", fontWeight = FontWeight.Bold)
|
||||
IconButton(onClick = { ProtocolManager.clearLogs() }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Clear")
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 400.dp)
|
||||
) {
|
||||
items(debugLogs.reversed()) { log ->
|
||||
Text(
|
||||
text = log,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
if (debugLogs.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No logs yet. Try sending a message.",
|
||||
color = Color.Gray,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showLogs = false }) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,9 +461,7 @@ private fun MessageBubble(
|
||||
|
||||
/**
|
||||
* Панель ввода сообщения 1:1 как в React Native
|
||||
* - Слева: круглая кнопка Attach (скрепка)
|
||||
* - Посередине: стеклянный инпут с текстом + справа emoji + send
|
||||
* - Справа: круглая кнопка Mic (уезжает когда есть текст)
|
||||
* Оптимизированная версия с правильным позиционированием
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@@ -410,63 +476,71 @@ private fun MessageInputBar(
|
||||
) {
|
||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Цвета как в RN
|
||||
// Цвета
|
||||
val circleBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.8f) else Color(0xFFF0F0F0).copy(alpha = 0.85f)
|
||||
val circleBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
|
||||
val circleIcon = if (isDarkTheme) Color.White else Color(0xFF333333)
|
||||
|
||||
val glassBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.9f) else Color(0xFFF0F0F0).copy(alpha = 0.92f)
|
||||
val glassBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
|
||||
val emojiIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.62f) else Color.Black.copy(alpha = 0.5f)
|
||||
|
||||
val panelBackground = if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
|
||||
|
||||
// === Анимации как в React Native ===
|
||||
val canSend = value.isNotBlank()
|
||||
// Состояние отправки
|
||||
val canSend = remember(value) { value.isNotBlank() }
|
||||
|
||||
// Easing functions
|
||||
// Easing
|
||||
val backEasing = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f)
|
||||
val smoothEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
|
||||
|
||||
// Send button animations
|
||||
val sendOpacity by animateFloatAsState(
|
||||
targetValue = if (canSend) 1f else 0f,
|
||||
animationSpec = tween(200, easing = smoothEasing),
|
||||
label = "sendOpacity"
|
||||
)
|
||||
// Анимации Send
|
||||
val sendScale by animateFloatAsState(
|
||||
targetValue = if (canSend) 1f else 0.5f,
|
||||
targetValue = if (canSend) 1f else 0f,
|
||||
animationSpec = tween(220, easing = backEasing),
|
||||
label = "sendScale"
|
||||
)
|
||||
|
||||
// Mic button animations
|
||||
// Анимации Mic
|
||||
val micOpacity by animateFloatAsState(
|
||||
targetValue = if (canSend) 0f else 1f,
|
||||
animationSpec = if (canSend) tween(150, easing = smoothEasing) else tween(200, delayMillis = 100, easing = smoothEasing),
|
||||
animationSpec = tween(200, easing = smoothEasing),
|
||||
label = "micOpacity"
|
||||
)
|
||||
val micTranslateX by animateFloatAsState(
|
||||
targetValue = if (canSend) 80f else 0f,
|
||||
animationSpec = if (canSend) tween(250, easing = smoothEasing) else tween(250, delayMillis = 80, easing = smoothEasing),
|
||||
animationSpec = tween(250, easing = smoothEasing),
|
||||
label = "micTranslateX"
|
||||
)
|
||||
|
||||
// Emoji button animation (сдвигается влево когда появляется send)
|
||||
val emojiTranslateX by animateFloatAsState(
|
||||
targetValue = if (canSend) -50f else 0f,
|
||||
animationSpec = tween(220, easing = smoothEasing),
|
||||
label = "emojiTranslateX"
|
||||
)
|
||||
|
||||
// Input margin animation (расширяется когда текст есть)
|
||||
// Input margin
|
||||
val inputEndMargin by animateDpAsState(
|
||||
targetValue = if (canSend) 0.dp else 56.dp,
|
||||
animationSpec = tween(220, easing = smoothEasing),
|
||||
label = "inputEndMargin"
|
||||
)
|
||||
|
||||
// Функция переключения emoji picker
|
||||
fun toggleEmojiPicker() {
|
||||
if (showEmojiPicker) {
|
||||
showEmojiPicker = false
|
||||
} else {
|
||||
// Скрываем клавиатуру и убираем фокус
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
showEmojiPicker = true
|
||||
}
|
||||
}
|
||||
|
||||
// Функция отправки
|
||||
fun handleSend() {
|
||||
if (value.isNotBlank()) {
|
||||
onSend()
|
||||
onValueChange("")
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -483,14 +557,17 @@ private fun MessageInputBar(
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
// === ATTACH BUTTON (круг слева) ===
|
||||
// ATTACH BUTTON
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(circleBackground)
|
||||
.border(1.dp, circleBorder, CircleShape)
|
||||
.clickable { /* TODO: Attach */ },
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) { /* TODO */ },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
@@ -503,89 +580,89 @@ private fun MessageInputBar(
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// === GLASS INPUT (расширяется вправо) ===
|
||||
// GLASS INPUT
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = inputEndMargin)
|
||||
.heightIn(min = 48.dp, max = 120.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(glassBackground)
|
||||
.border(1.dp, glassBorder, RoundedCornerShape(22.dp))
|
||||
.border(1.dp, glassBorder, RoundedCornerShape(24.dp))
|
||||
) {
|
||||
Row(
|
||||
// Text input
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.padding(start = 16.dp, end = 52.dp, top = 12.dp, bottom = 12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
// Apple Emoji Text Field (с PNG эмодзи)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(top = 8.dp, bottom = 8.dp, end = 70.dp), // место для emoji + send
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
AppleEmojiTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textColor = textColor,
|
||||
textSize = 16f,
|
||||
hint = "Message",
|
||||
hintColor = placeholderColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
AppleEmojiTextField(
|
||||
value = value,
|
||||
onValueChange = { newValue ->
|
||||
// Закрываем emoji picker при печати с клавиатуры
|
||||
if (showEmojiPicker && newValue.length > value.length) {
|
||||
// Не закрываем - пользователь мог выбрать emoji
|
||||
}
|
||||
onValueChange(newValue)
|
||||
},
|
||||
textColor = textColor,
|
||||
textSize = 16f,
|
||||
hint = "Message",
|
||||
hintColor = placeholderColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// === RIGHT ZONE (emoji + send) - абсолютная позиция справа внутри инпута ===
|
||||
// RIGHT ZONE - emoji или send
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 6.dp)
|
||||
.size(40.dp)
|
||||
) {
|
||||
// Emoji button (сдвигается влево при send)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.graphicsLayer { translationX = emojiTranslateX }
|
||||
.size(40.dp)
|
||||
.clickable {
|
||||
if (showEmojiPicker) {
|
||||
showEmojiPicker = false
|
||||
} else {
|
||||
keyboardController?.hide()
|
||||
showEmojiPicker = true
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions,
|
||||
contentDescription = "Emoji",
|
||||
tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
// Emoji button (показывается когда нет текста)
|
||||
if (!canSend) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = { toggleEmojiPicker() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions,
|
||||
contentDescription = "Emoji",
|
||||
tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Send button - красивая круглая кнопка с градиентом
|
||||
// Send button (показывается когда есть текст)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = sendScale
|
||||
scaleY = sendScale
|
||||
alpha = sendOpacity
|
||||
alpha = sendScale
|
||||
}
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF007AFF),
|
||||
Color(0xFF5AC8FA)
|
||||
)
|
||||
colors = listOf(Color(0xFF007AFF), Color(0xFF5AC8FA))
|
||||
)
|
||||
)
|
||||
.clickable(enabled = canSend) { onSend() },
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
enabled = canSend,
|
||||
onClick = { handleSend() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
@@ -599,7 +676,7 @@ private fun MessageInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
// === MIC BUTTON (абсолютная позиция справа, уезжает вправо) ===
|
||||
// MIC BUTTON
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
@@ -612,7 +689,11 @@ private fun MessageInputBar(
|
||||
.clip(CircleShape)
|
||||
.background(circleBackground)
|
||||
.border(1.dp, circleBorder, CircleShape)
|
||||
.clickable(enabled = !canSend) { /* TODO: Voice */ },
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
enabled = !canSend
|
||||
) { /* TODO */ },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
@@ -624,20 +705,11 @@ private fun MessageInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
// Apple Emoji Picker с PNG изображениями
|
||||
// Apple Emoji Picker
|
||||
AnimatedVisibility(
|
||||
visible = showEmojiPicker,
|
||||
enter = expandVertically(
|
||||
expandFrom = Alignment.Top,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
) + fadeIn(animationSpec = tween(150)),
|
||||
exit = shrinkVertically(
|
||||
shrinkTowards = Alignment.Top,
|
||||
animationSpec = tween(200)
|
||||
) + fadeOut(animationSpec = tween(100))
|
||||
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
|
||||
) {
|
||||
AppleEmojiPickerPanel(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -648,7 +720,6 @@ private fun MessageInputBar(
|
||||
)
|
||||
}
|
||||
|
||||
// Spacer для navigation bar когда эмодзи пикер НЕ открыт
|
||||
if (!showEmojiPicker) {
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.network.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* ViewModel для экрана чата - упрощенная рабочая версия
|
||||
* Без зависимости от MessageRepository
|
||||
*/
|
||||
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChatViewModel"
|
||||
}
|
||||
|
||||
// Текущий диалог
|
||||
private var opponentKey: String? = null
|
||||
private var myPublicKey: String? = null
|
||||
private var myPrivateKey: String? = null
|
||||
|
||||
// UI State - сообщения хранятся локально в памяти
|
||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _opponentTyping = MutableStateFlow(false)
|
||||
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
|
||||
|
||||
// Input state
|
||||
private val _inputText = MutableStateFlow("")
|
||||
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||
|
||||
// Защита от двойной отправки
|
||||
private var isSending = false
|
||||
|
||||
init {
|
||||
setupPacketListeners()
|
||||
}
|
||||
|
||||
private fun setupPacketListeners() {
|
||||
// Входящие сообщения
|
||||
ProtocolManager.waitPacket(0x06) { packet ->
|
||||
val msgPacket = packet as PacketMessage
|
||||
if (msgPacket.fromPublicKey == opponentKey || msgPacket.toPublicKey == opponentKey) {
|
||||
viewModelScope.launch {
|
||||
handleIncomingMessage(msgPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Доставка
|
||||
ProtocolManager.waitPacket(0x08) { packet ->
|
||||
val deliveryPacket = packet as PacketDelivery
|
||||
viewModelScope.launch {
|
||||
updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED)
|
||||
ProtocolManager.addLog("✓ Delivered: ${deliveryPacket.messageId.take(8)}...")
|
||||
}
|
||||
}
|
||||
|
||||
// Прочитано
|
||||
ProtocolManager.waitPacket(0x07) { packet ->
|
||||
val readPacket = packet as PacketRead
|
||||
viewModelScope.launch {
|
||||
updateMessageStatus(readPacket.messageId, MessageStatus.READ)
|
||||
ProtocolManager.addLog("✓✓ Read: ${readPacket.messageId.take(8)}...")
|
||||
}
|
||||
}
|
||||
|
||||
// Typing
|
||||
ProtocolManager.waitPacket(0x0B) { packet ->
|
||||
val typingPacket = packet as PacketTyping
|
||||
if (typingPacket.fromPublicKey == opponentKey) {
|
||||
showTypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIncomingMessage(packet: PacketMessage) {
|
||||
try {
|
||||
val message = ChatMessage(
|
||||
id = packet.messageId,
|
||||
text = "[Encrypted] ${packet.content.take(20)}...",
|
||||
isOutgoing = packet.fromPublicKey == myPublicKey,
|
||||
timestamp = Date(packet.timestamp),
|
||||
status = MessageStatus.DELIVERED
|
||||
)
|
||||
_messages.value = _messages.value + message
|
||||
ProtocolManager.addLog("📩 Incoming: ${packet.messageId.take(8)}...")
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog("❌ Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMessageStatus(messageId: String, status: MessageStatus) {
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.id == messageId) msg.copy(status = status) else msg
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить ключи пользователя
|
||||
*/
|
||||
fun setUserKeys(publicKey: String, privateKey: String) {
|
||||
myPublicKey = publicKey
|
||||
myPrivateKey = privateKey
|
||||
ProtocolManager.addLog("🔑 Keys set: ${publicKey.take(16)}...")
|
||||
}
|
||||
|
||||
/**
|
||||
* Открыть диалог
|
||||
*/
|
||||
fun openDialog(publicKey: String) {
|
||||
opponentKey = publicKey
|
||||
_messages.value = emptyList()
|
||||
ProtocolManager.addLog("💬 Dialog: ${publicKey.take(16)}...")
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить текст ввода
|
||||
*/
|
||||
fun updateInputText(text: String) {
|
||||
_inputText.value = text
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить сообщение - Optimistic UI
|
||||
*/
|
||||
fun sendMessage() {
|
||||
val text = _inputText.value.trim()
|
||||
val recipient = opponentKey
|
||||
val sender = myPublicKey
|
||||
val privateKey = myPrivateKey
|
||||
|
||||
if (text.isEmpty()) {
|
||||
ProtocolManager.addLog("❌ Empty text")
|
||||
return
|
||||
}
|
||||
if (recipient == null) {
|
||||
ProtocolManager.addLog("❌ No recipient")
|
||||
return
|
||||
}
|
||||
if (sender == null || privateKey == null) {
|
||||
ProtocolManager.addLog("❌ No keys - set via setUserKeys()")
|
||||
return
|
||||
}
|
||||
if (isSending) {
|
||||
ProtocolManager.addLog("⏳ Already sending...")
|
||||
return
|
||||
}
|
||||
|
||||
isSending = true
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
|
||||
// 1. Optimistic UI
|
||||
val optimisticMessage = ChatMessage(
|
||||
id = messageId,
|
||||
text = text,
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING
|
||||
)
|
||||
_messages.value = _messages.value + optimisticMessage
|
||||
_inputText.value = ""
|
||||
|
||||
ProtocolManager.addLog("📤 Send: \"${text.take(20)}...\"")
|
||||
|
||||
// 2. Отправка в фоне
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
ProtocolManager.addLog("🔐 Encrypting...")
|
||||
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(text, recipient)
|
||||
ProtocolManager.addLog("✓ Encrypted")
|
||||
|
||||
val packet = PacketMessage().apply {
|
||||
fromPublicKey = sender
|
||||
toPublicKey = recipient
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
this.messageId = messageId
|
||||
attachments = emptyList()
|
||||
}
|
||||
|
||||
ProtocolManager.addLog("📡 Sending packet...")
|
||||
ProtocolManager.send(packet)
|
||||
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
ProtocolManager.addLog("✓ Sent!")
|
||||
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog("❌ Error: ${e.message}")
|
||||
Log.e(TAG, "Send error", e)
|
||||
} finally {
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showTypingIndicator() {
|
||||
_opponentTyping.value = true
|
||||
viewModelScope.launch {
|
||||
kotlinx.coroutines.delay(3000)
|
||||
_opponentTyping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
opponentKey = null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user