feat: Implement floating input bar in ChatDetailScreen and ensure transparent backgrounds in AppleEmojiEditText

This commit is contained in:
k1ngsterr1
2026-01-12 03:52:17 +05:00
parent ec299bb415
commit 8237c72c17
5 changed files with 697 additions and 154 deletions

View File

@@ -21,7 +21,7 @@
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.RosettaAndroid"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustNothing">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -30,8 +30,8 @@ class Protocol(
) {
companion object {
private const val TAG = "RosettaProtocol"
private const val RECONNECT_INTERVAL = 10000L // 10 seconds
private const val MAX_RECONNECT_ATTEMPTS = 5
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
private const val MAX_RECONNECT_ATTEMPTS = 10 // Увеличил лимит
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
}
@@ -296,22 +296,42 @@ class Protocol(
}
private fun handleDisconnect() {
val previousState = _state.value
_state.value = ProtocolState.DISCONNECTED
handshakeComplete = false
handshakeJob?.cancel()
heartbeatJob?.cancel()
if (!isManuallyClosed && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++
log("🔄 Reconnecting in ${RECONNECT_INTERVAL}ms (attempt $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS)")
scope.launch {
delay(RECONNECT_INTERVAL)
connect()
// Автоматический reconnect (как в Архиве)
if (!isManuallyClosed) {
// Сбрасываем счетчик если до этого были подключены
if (previousState == ProtocolState.AUTHENTICATED || previousState == ProtocolState.CONNECTED) {
log("🔄 Connection lost, will reconnect...")
}
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++
val delay = RECONNECT_INTERVAL * minOf(reconnectAttempts, 3) // Exponential backoff до 15 секунд
log("🔄 Reconnecting in ${delay}ms (attempt $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS)")
scope.launch {
delay(delay)
if (!isManuallyClosed) {
connect()
}
}
} else {
log("❌ Max reconnect attempts reached, resetting counter...")
// Сбрасываем счетчик и пробуем снова через большой интервал
reconnectAttempts = 0
scope.launch {
delay(30000) // 30 секунд перед следующей серией попыток
if (!isManuallyClosed) {
log("🔄 Restarting reconnection attempts...")
connect()
}
}
}
} else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
log("❌ Max reconnect attempts reached")
_lastError.value = "Unable to connect to server"
}
}

View File

@@ -4,9 +4,11 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -172,6 +174,7 @@ fun ChatDetailScreen(
) {
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
// Цвета как в React Native themes.ts
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -194,16 +197,18 @@ fun ChatDetailScreen(
// Telegram-style scroll tracking
var wasManualScroll by remember { mutableStateOf(false) }
var isAtBottom by remember { mutableStateOf(true) }
// Кнопка появляется после 3+ сообщений от начала (позже)
val isAtBottom by remember {
derivedStateOf {
listState.firstVisibleItemIndex < 3 && listState.firstVisibleItemScrollOffset < 100
}
}
// Флаг для скрытия кнопки scroll при отправке (чтобы не мигала)
var isSendingMessage by remember { mutableStateOf(false) }
// Track if user is at bottom of list
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
val isAtTop =
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
isAtBottom = isAtTop
}
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
val isSelectionMode = selectedMessages.isNotEmpty()
// 🔥 Быстрое закрытие с fade-out анимацией
val hideKeyboardAndBack: () -> Unit = {
@@ -242,6 +247,11 @@ fun ChatDetailScreen(
val isTyping by viewModel.opponentTyping.collectAsState()
val isOnline by viewModel.opponentOnline.collectAsState()
// 🔥 Reply/Forward state
val replyMessages by viewModel.replyMessages.collectAsState()
val isForwardMode by viewModel.isForwardMode.collectAsState()
val hasReply = replyMessages.isNotEmpty()
// 🔥 Добавляем информацию о датах к сообщениям
// В reversed layout (новые внизу) - показываем дату ПОСЛЕ сообщения (визуально сверху)
val messagesWithDates =
@@ -277,7 +287,7 @@ fun ChatDetailScreen(
val chatSubtitle =
when {
isSavedMessages -> "Notes"
isTyping -> "typing..."
isTyping -> "" // Пустая строка, используем компонент TypingIndicator
isOnline -> "online"
else -> "offline"
}
@@ -297,6 +307,10 @@ fun ChatDetailScreen(
LaunchedEffect(user.publicKey) {
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
viewModel.openDialog(user.publicKey, user.title, user.username)
// Подписываемся на онлайн статус собеседника
if (!isSavedMessages) {
viewModel.subscribeToOnlineStatus()
}
}
// Отмечаем сообщения как прочитанные когда они видны
@@ -306,13 +320,13 @@ fun ChatDetailScreen(
}
}
// Telegram-style: Прокрутка при новых сообщениях только если пользователь в низу
// Telegram-style: Прокрутка при новых сообщениях
// Всегда скроллим к последнему при изменении количества сообщений
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
// При первой загрузке всегда скроллим вниз
if (!wasManualScroll || isAtBottom) {
listState.animateScrollToItem(0)
}
// Всегда скроллим вниз при новом сообщении
listState.animateScrollToItem(0)
wasManualScroll = false
}
}
@@ -333,6 +347,92 @@ fun ChatDetailScreen(
Scaffold(
topBar = {
// 🔥 SELECTION HEADER (появляется при выборе сообщений)
AnimatedVisibility(
visible = isSelectionMode,
enter = fadeIn(animationSpec = tween(200)) + slideInVertically(
initialOffsetY = { -it },
animationSpec = tween(250, easing = TelegramEasing)
),
exit = fadeOut(animationSpec = tween(150)) + slideOutVertically(
targetOffsetY = { -it },
animationSpec = tween(200, easing = TelegramEasing)
)
) {
Box(modifier = Modifier
.fillMaxWidth()
.background(if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.height(56.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Left: X (cancel) + Count
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { selectedMessages = emptySet() }) {
Icon(
Icons.Default.Close,
contentDescription = "Cancel",
tint = textColor
)
}
Text(
"${selectedMessages.size}",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = textColor
)
}
// Right: Copy button only
IconButton(
onClick = {
// Копируем текст выбранных сообщений
val textToCopy = messages
.filter { selectedMessages.contains(it.id) }
.sortedBy { it.timestamp }
.joinToString("\n\n") { msg ->
val time = SimpleDateFormat("HH:mm", Locale.getDefault())
.format(msg.timestamp)
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}"
}
clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(textToCopy))
selectedMessages = emptySet()
}
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy",
tint = textColor
)
}
}
// Bottom line
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(0.5.dp)
.background(
if (isDarkTheme) Color.White.copy(alpha = 0.15f)
else Color.Black.copy(alpha = 0.1f)
)
)
}
}
// NORMAL HEADER (скрывается при выборе)
AnimatedVisibility(
visible = !isSelectionMode,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(150))
) {
// Telegram-style TopAppBar - solid background без blur
Box(modifier = Modifier.fillMaxWidth().background(headerBackground)) {
Row(
@@ -386,6 +486,16 @@ fun ChatDetailScreen(
color = avatarColors.textColor
)
}
// 🟢 Online indicator
if (!isSavedMessages && isOnline) {
Box(
modifier = Modifier
.size(12.dp)
.align(Alignment.BottomEnd)
.background(Color(0xFF38B24D), CircleShape)
.border(2.dp, headerBackground, CircleShape)
)
}
}
Spacer(modifier = Modifier.width(12.dp))
@@ -413,21 +523,25 @@ fun ChatDetailScreen(
VerifiedBadge(verified = user.verified, size = 16)
}
}
Text(
text = chatSubtitle,
fontSize = 13.sp,
color =
when {
isSavedMessages -> secondaryTextColor
isTyping -> PrimaryBlue // Синий когда печатает
isOnline ->
Color(
0xFF38B24D
) // Зелёный когда онлайн
else -> secondaryTextColor // Серый для offline
},
maxLines = 1
)
// Typing indicator или subtitle
if (isTyping) {
TypingIndicator(isDarkTheme = isDarkTheme)
} else {
Text(
text = chatSubtitle,
fontSize = 13.sp,
color =
when {
isSavedMessages -> secondaryTextColor
isOnline ->
Color(
0xFF38B24D
) // Зелёный когда онлайн
else -> secondaryTextColor // Серый для offline
},
maxLines = 1
)
}
}
// Кнопки действий
@@ -549,18 +663,18 @@ fun ChatDetailScreen(
)
)
}
} // Закрытие AnimatedVisibility для normal header
},
containerColor = backgroundColor // Фон всего чата
) { paddingValues ->
// 🔥 Простой Column - сообщения сверху, инпут снизу
// Клавиатура сама поднимает контент через adjustResize
Column(
// 🔥 Box с overlay - инпут плавает поверх сообщений
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Список сообщений - занимает всё доступное пространство
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
// Список сообщений - занимает весь экран
Box(modifier = Modifier.fillMaxSize()) {
if (messages.isEmpty()) {
// Пустое состояние
Column(
@@ -620,13 +734,13 @@ fun ChatDetailScreen(
}
}
),
// padding для контента списка
// padding для контента списка - добавляем снизу место для инпута
contentPadding =
PaddingValues(
start = 8.dp,
end = 8.dp,
top = 8.dp,
bottom = 8.dp
bottom = 100.dp // Место для floating input (с запасом)
),
reverseLayout = true
) {
@@ -647,7 +761,26 @@ fun ChatDetailScreen(
MessageBubble(
message = message,
isDarkTheme = isDarkTheme,
index = index
index = index,
isSelected = selectedMessages.contains(message.id),
onLongClick = {
// Toggle selection on long press
selectedMessages = if (selectedMessages.contains(message.id)) {
selectedMessages - message.id
} else {
selectedMessages + message.id
}
},
onClick = {
// If in selection mode, toggle selection
if (isSelectionMode) {
selectedMessages = if (selectedMessages.contains(message.id)) {
selectedMessages - message.id
} else {
selectedMessages + message.id
}
}
}
)
}
}
@@ -660,7 +793,7 @@ fun ChatDetailScreen(
Box(
modifier =
Modifier.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp)
.padding(end = 16.dp, bottom = 80.dp)
.size(44.dp)
.shadow(
elevation = 8.dp,
@@ -728,33 +861,166 @@ fun ChatDetailScreen(
}
}
// 🔥 INPUT BAR - обычный элемент внизу Column
MessageInputBar(
value = inputText,
onValueChange = {
viewModel.updateInputText(it)
// Отправляем индикатор печатания
if (it.isNotEmpty() && !isSavedMessages) {
viewModel.sendTypingIndicator()
// 🔥 FLOATING INPUT BAR - плавает поверх сообщений, поднимается с клавиатурой
// Скрываем когда в режиме выбора
AnimatedVisibility(
visible = !isSelectionMode,
enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }),
exit = fadeOut(tween(150)) + slideOutVertically(targetOffsetY = { it }),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.imePadding()
) {
// Input bar с встроенным reply preview (как в React Native)
MessageInputBar(
value = inputText,
onValueChange = {
viewModel.updateInputText(it)
// Отправляем индикатор печатания
if (it.isNotEmpty() && !isSavedMessages) {
viewModel.sendTypingIndicator()
}
},
onSend = {
// Скрываем кнопку scroll на время отправки
isSendingMessage = true
viewModel.sendMessage()
// Скроллим к новому сообщению
scope.launch {
delay(100)
listState.animateScrollToItem(0)
delay(300) // Ждём завершения анимации
isSendingMessage = false
}
},
isDarkTheme = isDarkTheme,
backgroundColor = inputBackgroundColor,
textColor = textColor,
placeholderColor = secondaryTextColor,
// Reply state
replyMessages = replyMessages,
isForwardMode = isForwardMode,
onCloseReply = { viewModel.clearReplyMessages() },
chatTitle = chatTitle
)
}
// 🔥 SELECTION ACTION BAR - Reply/Forward (появляется при выборе сообщений)
// Стеклянный стиль как у инпута
AnimatedVisibility(
visible = isSelectionMode,
enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }),
exit = fadeOut(tween(150)) + slideOutVertically(targetOffsetY = { it }),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
.navigationBarsPadding()
) {
// Glass container
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(
if (isDarkTheme) Color(0xFF2A2A2A).copy(alpha = 0.85f)
else Color(0xFFF2F3F5).copy(alpha = 0.92f)
)
.border(
width = 0.5.dp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.08f)
else Color.Black.copy(alpha = 0.06f),
shape = RoundedCornerShape(20.dp)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Reply button - стеклянная кнопка
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(14.dp))
.background(
if (isDarkTheme) Color.White.copy(alpha = 0.08f)
else Color.Black.copy(alpha = 0.04f)
)
.clickable {
val selectedMsgs = messages
.filter { selectedMessages.contains(it.id) }
.sortedBy { it.timestamp }
viewModel.setReplyMessages(selectedMsgs)
selectedMessages = emptySet()
}
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.Reply,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Reply",
color = PrimaryBlue,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
}
},
onSend = {
// Скрываем кнопку scroll на время отправки
isSendingMessage = true
viewModel.sendMessage()
// Скроллим к новому сообщению
scope.launch {
delay(100)
listState.animateScrollToItem(0)
delay(300) // Ждём завершения анимации
isSendingMessage = false
// Forward button - стеклянная кнопка
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(14.dp))
.background(
if (isDarkTheme) Color.White.copy(alpha = 0.08f)
else Color.Black.copy(alpha = 0.04f)
)
.clickable {
val selectedMsgs = messages
.filter { selectedMessages.contains(it.id) }
.sortedBy { it.timestamp }
viewModel.setForwardMessages(selectedMsgs)
selectedMessages = emptySet()
}
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.Forward,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Forward",
color = PrimaryBlue,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
}
},
isDarkTheme = isDarkTheme,
backgroundColor = inputBackgroundColor,
textColor = textColor,
placeholderColor = secondaryTextColor
)
}
}
}
}
}
} // Закрытие Box с fade-in
@@ -904,11 +1170,31 @@ fun rememberMessageEnterAnimation(messageId: String): Pair<Float, Float> {
}
/** 🚀 Пузырек сообщения Telegram-style */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun MessageBubble(message: ChatMessage, isDarkTheme: Boolean, index: Int = 0) {
private fun MessageBubble(
message: ChatMessage,
isDarkTheme: Boolean,
index: Int = 0,
isSelected: Boolean = false,
onLongClick: () -> Unit = {},
onClick: () -> Unit = {}
) {
// Telegram-style enter animation
val (alpha, translationY) = rememberMessageEnterAnimation(message.id)
// Selection animation
val selectionScale by animateFloatAsState(
targetValue = if (isSelected) 0.95f else 1f,
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
label = "selectionScale"
)
val selectionAlpha by animateFloatAsState(
targetValue = if (isSelected) 0.85f else 1f,
animationSpec = tween(150),
label = "selectionAlpha"
)
// Telegram colors
val bubbleColor =
if (message.isOutgoing) {
@@ -943,15 +1229,42 @@ private fun MessageBubble(message: ChatMessage, isDarkTheme: Boolean, index: Int
Modifier.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 1.dp)
.graphicsLayer {
this.alpha = alpha
this.alpha = alpha * selectionAlpha
this.translationY = translationY
this.scaleX = selectionScale
this.scaleY = selectionScale
},
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
) {
// Checkbox для выбранных сообщений
AnimatedVisibility(
visible = isSelected,
enter = fadeIn() + scaleIn(initialScale = 0.5f),
exit = fadeOut() + scaleOut(targetScale = 0.5f)
) {
Box(
modifier = Modifier
.padding(end = 8.dp)
.align(Alignment.CenterVertically)
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Selected",
tint = PrimaryBlue,
modifier = Modifier.size(24.dp)
)
}
}
Box(
modifier =
Modifier.widthIn(max = 300.dp)
.shadow(elevation = 1.dp, shape = bubbleShape, clip = false)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
// Тень только для исходящих
.then(if (message.isOutgoing) Modifier.shadow(elevation = 1.dp, shape = bubbleShape, clip = false) else Modifier)
.clip(bubbleShape)
.background(bubbleColor)
.padding(horizontal = 12.dp, vertical = 7.dp)
@@ -1046,8 +1359,14 @@ private fun MessageInputBar(
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
placeholderColor: Color
placeholderColor: Color,
// Reply state (как в React Native)
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
isForwardMode: Boolean = false,
onCloseReply: () -> Unit = {},
chatTitle: String = ""
) {
val hasReply = replyMessages.isNotEmpty()
var showEmojiPicker by remember { mutableStateOf(false) }
// Флаг для запуска закрытия клавиатуры перед открытием emoji picker
var pendingEmojiPicker by remember { mutableStateOf(false) }
@@ -1111,12 +1430,13 @@ private fun MessageInputBar(
modifier = Modifier.fillMaxWidth()
) {
// 🔥 TELEGRAM-STYLE FLOATING LIQUID GLASS INPUT
Row(
// Используем Column вместо Row для reply panel внутри
Column(
modifier =
Modifier.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 16.dp)
// Инпут растёт вверх до 6 строк (~140dp)
.heightIn(min = 44.dp, max = 140.dp)
.padding(horizontal = 8.dp, vertical = 16.dp)
// Инпут растёт вверх до 6 строк (~140dp) + reply
.heightIn(min = 44.dp, max = if (hasReply) 200.dp else 140.dp)
.shadow(
elevation = 4.dp,
shape = RoundedCornerShape(22.dp),
@@ -1165,70 +1485,137 @@ private fun MessageInputBar(
),
shape = RoundedCornerShape(22.dp)
)
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.Bottom
) {
// EMOJI BUTTON
Box(
modifier =
Modifier.align(Alignment.Bottom)
.size(36.dp)
.clip(CircleShape)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = { toggleEmojiPicker() }
),
contentAlignment = Alignment.Center
// 🔥 REPLY PANEL внутри glass (как в React Native)
AnimatedVisibility(
visible = hasReply,
enter = fadeIn(tween(150)) + expandVertically(),
exit = fadeOut(tween(100)) + shrinkVertically()
) {
Icon(
if (showEmojiPicker) Icons.Default.Keyboard
else Icons.Default.SentimentSatisfiedAlt,
contentDescription = "Emoji",
tint =
if (showEmojiPicker) PrimaryBlue
else {
if (isDarkTheme) Color.White.copy(alpha = 0.65f)
else Color.Black.copy(alpha = 0.55f)
},
modifier = Modifier.size(26.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 6.dp, top = 10.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Вертикальная синяя линия (как в React Native)
Box(
modifier = Modifier
.width(3.dp)
.height(36.dp)
.background(PrimaryBlue, RoundedCornerShape(1.5.dp))
)
Spacer(modifier = Modifier.width(10.dp))
// Контент reply
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (isForwardMode) "Forward message${if (replyMessages.size > 1) "s" else ""}"
else "Reply to ${if (replyMessages.size == 1 && !replyMessages.first().isOutgoing) chatTitle else "yourself"}",
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = PrimaryBlue,
maxLines = 1
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (replyMessages.size == 1) {
val msg = replyMessages.first()
val shortText = msg.text.take(40)
if (shortText.length < msg.text.length) "$shortText..." else shortText
} else "${replyMessages.size} messages",
fontSize = 13.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f)
else Color.Black.copy(alpha = 0.5f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// Кнопка X
IconButton(
onClick = onCloseReply,
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Close,
contentDescription = "Cancel",
tint = if (isDarkTheme) Color.White.copy(alpha = 0.5f)
else Color.Black.copy(alpha = 0.4f),
modifier = Modifier.size(18.dp)
)
}
}
}
// TEXT INPUT
Box(
modifier =
Modifier.weight(1f)
.align(Alignment.CenterVertically)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart
// Input Row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.Bottom
) {
AppleEmojiTextField(
value = value,
onValueChange = { newValue -> onValueChange(newValue) },
textColor = textColor,
textSize = 17f,
hint = "Message",
hintColor =
if (isDarkTheme) Color.White.copy(alpha = 0.35f)
else Color.Black.copy(alpha = 0.35f),
modifier = Modifier.fillMaxWidth()
)
}
// EMOJI BUTTON
Box(
modifier =
Modifier.align(Alignment.Bottom)
.size(36.dp)
.clip(CircleShape)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = { toggleEmojiPicker() }
),
contentAlignment = Alignment.Center
) {
Icon(
if (showEmojiPicker) Icons.Default.Keyboard
else Icons.Default.SentimentSatisfiedAlt,
contentDescription = "Emoji",
tint =
if (showEmojiPicker) PrimaryBlue
else {
if (isDarkTheme) Color.White.copy(alpha = 0.65f)
else Color.Black.copy(alpha = 0.55f)
},
modifier = Modifier.size(26.dp)
)
}
// ATTACH BUTTON
Box(
modifier =
Modifier.align(Alignment.Bottom)
.size(36.dp)
.clip(CircleShape)
.clickable(
interactionSource = interactionSource,
indication = null
) { /* TODO: Attach */},
contentAlignment = Alignment.Center
) {
Icon(
// TEXT INPUT
Box(
modifier =
Modifier.weight(1f)
.align(Alignment.CenterVertically)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart
) {
AppleEmojiTextField(
value = value,
onValueChange = { newValue -> onValueChange(newValue) },
textColor = textColor,
textSize = 17f,
hint = "Message",
hintColor =
if (isDarkTheme) Color.White.copy(alpha = 0.35f)
else Color.Black.copy(alpha = 0.35f),
modifier = Modifier.fillMaxWidth()
)
}
// ATTACH BUTTON
Box(
modifier =
Modifier.align(Alignment.Bottom)
.size(36.dp)
.clip(CircleShape)
.clickable(
interactionSource = interactionSource,
indication = null
) { /* TODO: Attach */},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Attachment,
contentDescription = "Attach",
tint =
@@ -1287,7 +1674,8 @@ private fun MessageInputBar(
}
}
}
}
} // End of Input Row
} // End of Glass Column
// Apple Emoji Picker
AnimatedVisibility(
@@ -1303,3 +1691,46 @@ private fun MessageInputBar(
}
}
}
/**
* 💬 Typing Indicator с анимацией точек (как в Telegram)
*/
@Composable
fun TypingIndicator(isDarkTheme: Boolean) {
val infiniteTransition = rememberInfiniteTransition(label = "typing")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = "typing",
fontSize = 13.sp,
color = Color(0xFF38B24D) // Зелёный цвет как в Telegram
)
// 3 анимированные точки
repeat(3) { index ->
val offsetY by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = -4f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 600,
delayMillis = index * 100,
easing = FastOutSlowInEasing
),
repeatMode = RepeatMode.Reverse
),
label = "dot$index"
)
Text(
text = ".",
fontSize = 13.sp,
color = Color(0xFF38B24D),
modifier = Modifier.offset(y = offsetY.dp)
)
}
}
}

View File

@@ -74,6 +74,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow()
// 🔥 Reply/Forward state
data class ReplyMessage(
val messageId: String,
val text: String,
val timestamp: Long,
val isOutgoing: Boolean
)
private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList())
val replyMessages: StateFlow<List<ReplyMessage>> = _replyMessages.asStateFlow()
private val _isForwardMode = MutableStateFlow(false)
val isForwardMode: StateFlow<Boolean> = _isForwardMode.asStateFlow()
// Пагинация
private var currentOffset = 0
private var hasMoreMessages = true
@@ -414,19 +427,63 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_inputText.value = text
}
/**
* 🔥 Установить сообщения для Reply
*/
fun setReplyMessages(messages: List<ChatMessage>) {
_replyMessages.value = messages.map { msg ->
ReplyMessage(
messageId = msg.id,
text = msg.text,
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing
)
}
_isForwardMode.value = false
ProtocolManager.addLog("📝 Reply set: ${messages.size} messages")
}
/**
* 🔥 Установить сообщения для Forward
*/
fun setForwardMessages(messages: List<ChatMessage>) {
_replyMessages.value = messages.map { msg ->
ReplyMessage(
messageId = msg.id,
text = msg.text,
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing
)
}
_isForwardMode.value = true
ProtocolManager.addLog("➡️ Forward set: ${messages.size} messages")
}
/**
* 🔥 Очистить reply/forward
*/
fun clearReplyMessages() {
_replyMessages.value = emptyList()
_isForwardMode.value = false
}
/**
* 🚀 Оптимизированная отправка сообщения
* - Optimistic UI (мгновенное отображение)
* - Шифрование в IO потоке
* - Сохранение в БД в IO потоке
* - Поддержка Reply/Forward
*/
fun sendMessage() {
val text = _inputText.value.trim()
val recipient = opponentKey
val sender = myPublicKey
val privateKey = myPrivateKey
val replyMsgs = _replyMessages.value
val isForward = _isForwardMode.value
if (text.isEmpty()) {
// Разрешаем отправку пустого текста если есть reply/forward
if (text.isEmpty() && replyMsgs.isEmpty()) {
ProtocolManager.addLog("❌ Empty text")
return
}
@@ -448,10 +505,28 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
// 🔥 Формируем текст с reply/forward
val fullText = buildString {
if (replyMsgs.isNotEmpty()) {
if (isForward) {
append("📨 Forwarded:\n")
} else {
append("↩️ Reply:\n")
}
replyMsgs.forEach { msg ->
append("\"${msg.text.take(100)}${if (msg.text.length > 100) "..." else ""}\"\n")
}
if (text.isNotEmpty()) {
append("\n")
}
}
append(text)
}
// 1. 🚀 Optimistic UI - мгновенно показываем сообщение
val optimisticMessage = ChatMessage(
id = messageId,
text = text,
text = fullText,
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING
@@ -459,16 +534,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_messages.value = _messages.value + optimisticMessage
_inputText.value = ""
// Кэшируем текст
decryptionCache[messageId] = text
// Очищаем reply после отправки
clearReplyMessages()
ProtocolManager.addLog("📤 Sending: \"${text.take(20)}...\"")
// Кэшируем текст
decryptionCache[messageId] = fullText
ProtocolManager.addLog("📤 Sending: \"${fullText.take(20)}...\"")
// 2. 🔥 Шифрование и отправка в IO потоке
viewModelScope.launch(Dispatchers.IO) {
try {
// Шифрование (тяжёлая операция)
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(text, recipient)
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(fullText, recipient)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -494,7 +572,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 4. 💾 Сохранение в БД (уже в IO потоке)
saveMessageToDatabase(
messageId = messageId,
text = text,
text = fullText,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
timestamp = timestamp,
@@ -502,7 +580,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
delivered = 1 // SENT - сервер принял
)
saveDialog(text, timestamp)
saveDialog(fullText, timestamp)
} catch (e: Exception) {
Log.e(TAG, "Send error", e)

View File

@@ -13,9 +13,12 @@ import android.util.LruCache
import android.view.Gravity
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.FrameLayout
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.foundation.background
import androidx.compose.ui.viewinterop.AndroidView
import java.util.regex.Pattern
@@ -213,14 +216,25 @@ fun AppleEmojiTextField(
setHint(hint)
setTextSize(textSize)
onTextChange = onValueChange
// Убираем все возможные фоны у EditText
background = null
setBackgroundColor(android.graphics.Color.TRANSPARENT)
}
},
update = { view ->
if (view.text.toString() != value) {
view.setTextWithEmojis(value)
}
// Гарантируем прозрачность у EditText
view.background = null
// 🔥 Убираем фон у AndroidViewHolder (parent FrameLayout)
(view.parent as? FrameLayout)?.apply {
setBackgroundColor(android.graphics.Color.TRANSPARENT)
background = null
}
},
modifier = modifier
// 🔥 Прозрачный фон для Compose контейнера
modifier = modifier.background(Color.Transparent)
)
}