feat: Implement floating input bar in ChatDetailScreen and ensure transparent backgrounds in AppleEmojiEditText
This commit is contained in:
@@ -21,7 +21,7 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.RosettaAndroid"
|
android:theme="@style/Theme.RosettaAndroid"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustNothing">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class Protocol(
|
|||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "RosettaProtocol"
|
private const val TAG = "RosettaProtocol"
|
||||||
private const val RECONNECT_INTERVAL = 10000L // 10 seconds
|
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
|
||||||
private const val MAX_RECONNECT_ATTEMPTS = 5
|
private const val MAX_RECONNECT_ATTEMPTS = 10 // Увеличил лимит
|
||||||
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
|
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,22 +296,42 @@ class Protocol(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDisconnect() {
|
private fun handleDisconnect() {
|
||||||
|
val previousState = _state.value
|
||||||
_state.value = ProtocolState.DISCONNECTED
|
_state.value = ProtocolState.DISCONNECTED
|
||||||
handshakeComplete = false
|
handshakeComplete = false
|
||||||
handshakeJob?.cancel()
|
handshakeJob?.cancel()
|
||||||
heartbeatJob?.cancel()
|
heartbeatJob?.cancel()
|
||||||
|
|
||||||
if (!isManuallyClosed && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
// Автоматический reconnect (как в Архиве)
|
||||||
reconnectAttempts++
|
if (!isManuallyClosed) {
|
||||||
log("🔄 Reconnecting in ${RECONNECT_INTERVAL}ms (attempt $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS)")
|
// Сбрасываем счетчик если до этого были подключены
|
||||||
|
if (previousState == ProtocolState.AUTHENTICATED || previousState == ProtocolState.CONNECTED) {
|
||||||
scope.launch {
|
log("🔄 Connection lost, will reconnect...")
|
||||||
delay(RECONNECT_INTERVAL)
|
}
|
||||||
connect()
|
|
||||||
|
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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -172,6 +174,7 @@ fun ChatDetailScreen(
|
|||||||
) {
|
) {
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||||
// Цвета как в React Native themes.ts
|
// Цвета как в React Native themes.ts
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
@@ -194,16 +197,18 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
// Telegram-style scroll tracking
|
// Telegram-style scroll tracking
|
||||||
var wasManualScroll by remember { mutableStateOf(false) }
|
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 при отправке (чтобы не мигала)
|
// Флаг для скрытия кнопки scroll при отправке (чтобы не мигала)
|
||||||
var isSendingMessage by remember { mutableStateOf(false) }
|
var isSendingMessage by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Track if user is at bottom of list
|
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
|
||||||
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
val isAtTop =
|
val isSelectionMode = selectedMessages.isNotEmpty()
|
||||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
|
||||||
isAtBottom = isAtTop
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔥 Быстрое закрытие с fade-out анимацией
|
// 🔥 Быстрое закрытие с fade-out анимацией
|
||||||
val hideKeyboardAndBack: () -> Unit = {
|
val hideKeyboardAndBack: () -> Unit = {
|
||||||
@@ -242,6 +247,11 @@ fun ChatDetailScreen(
|
|||||||
val isTyping by viewModel.opponentTyping.collectAsState()
|
val isTyping by viewModel.opponentTyping.collectAsState()
|
||||||
val isOnline by viewModel.opponentOnline.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 (новые внизу) - показываем дату ПОСЛЕ сообщения (визуально сверху)
|
// В reversed layout (новые внизу) - показываем дату ПОСЛЕ сообщения (визуально сверху)
|
||||||
val messagesWithDates =
|
val messagesWithDates =
|
||||||
@@ -277,7 +287,7 @@ fun ChatDetailScreen(
|
|||||||
val chatSubtitle =
|
val chatSubtitle =
|
||||||
when {
|
when {
|
||||||
isSavedMessages -> "Notes"
|
isSavedMessages -> "Notes"
|
||||||
isTyping -> "typing..."
|
isTyping -> "" // Пустая строка, используем компонент TypingIndicator
|
||||||
isOnline -> "online"
|
isOnline -> "online"
|
||||||
else -> "offline"
|
else -> "offline"
|
||||||
}
|
}
|
||||||
@@ -297,6 +307,10 @@ fun ChatDetailScreen(
|
|||||||
LaunchedEffect(user.publicKey) {
|
LaunchedEffect(user.publicKey) {
|
||||||
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
||||||
viewModel.openDialog(user.publicKey, user.title, user.username)
|
viewModel.openDialog(user.publicKey, user.title, user.username)
|
||||||
|
// Подписываемся на онлайн статус собеседника
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
viewModel.subscribeToOnlineStatus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отмечаем сообщения как прочитанные когда они видны
|
// Отмечаем сообщения как прочитанные когда они видны
|
||||||
@@ -306,13 +320,13 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram-style: Прокрутка при новых сообщениях только если пользователь в низу
|
// Telegram-style: Прокрутка при новых сообщениях
|
||||||
|
// Всегда скроллим к последнему при изменении количества сообщений
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messages.size) {
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
// При первой загрузке всегда скроллим вниз
|
// Всегда скроллим вниз при новом сообщении
|
||||||
if (!wasManualScroll || isAtBottom) {
|
listState.animateScrollToItem(0)
|
||||||
listState.animateScrollToItem(0)
|
wasManualScroll = false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +347,92 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
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
|
// Telegram-style TopAppBar - solid background без blur
|
||||||
Box(modifier = Modifier.fillMaxWidth().background(headerBackground)) {
|
Box(modifier = Modifier.fillMaxWidth().background(headerBackground)) {
|
||||||
Row(
|
Row(
|
||||||
@@ -386,6 +486,16 @@ fun ChatDetailScreen(
|
|||||||
color = avatarColors.textColor
|
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))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
@@ -413,21 +523,25 @@ fun ChatDetailScreen(
|
|||||||
VerifiedBadge(verified = user.verified, size = 16)
|
VerifiedBadge(verified = user.verified, size = 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
// Typing indicator или subtitle
|
||||||
text = chatSubtitle,
|
if (isTyping) {
|
||||||
fontSize = 13.sp,
|
TypingIndicator(isDarkTheme = isDarkTheme)
|
||||||
color =
|
} else {
|
||||||
when {
|
Text(
|
||||||
isSavedMessages -> secondaryTextColor
|
text = chatSubtitle,
|
||||||
isTyping -> PrimaryBlue // Синий когда печатает
|
fontSize = 13.sp,
|
||||||
isOnline ->
|
color =
|
||||||
Color(
|
when {
|
||||||
0xFF38B24D
|
isSavedMessages -> secondaryTextColor
|
||||||
) // Зелёный когда онлайн
|
isOnline ->
|
||||||
else -> secondaryTextColor // Серый для offline
|
Color(
|
||||||
},
|
0xFF38B24D
|
||||||
maxLines = 1
|
) // Зелёный когда онлайн
|
||||||
)
|
else -> secondaryTextColor // Серый для offline
|
||||||
|
},
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопки действий
|
// Кнопки действий
|
||||||
@@ -549,18 +663,18 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} // Закрытие AnimatedVisibility для normal header
|
||||||
},
|
},
|
||||||
containerColor = backgroundColor // Фон всего чата
|
containerColor = backgroundColor // Фон всего чата
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
// 🔥 Простой Column - сообщения сверху, инпут снизу
|
// 🔥 Box с overlay - инпут плавает поверх сообщений
|
||||||
// Клавиатура сама поднимает контент через adjustResize
|
Box(
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
// Список сообщений - занимает всё доступное пространство
|
// Список сообщений - занимает весь экран
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
if (messages.isEmpty()) {
|
if (messages.isEmpty()) {
|
||||||
// Пустое состояние
|
// Пустое состояние
|
||||||
Column(
|
Column(
|
||||||
@@ -620,13 +734,13 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
// padding для контента списка
|
// padding для контента списка - добавляем снизу место для инпута
|
||||||
contentPadding =
|
contentPadding =
|
||||||
PaddingValues(
|
PaddingValues(
|
||||||
start = 8.dp,
|
start = 8.dp,
|
||||||
end = 8.dp,
|
end = 8.dp,
|
||||||
top = 8.dp,
|
top = 8.dp,
|
||||||
bottom = 8.dp
|
bottom = 100.dp // Место для floating input (с запасом)
|
||||||
),
|
),
|
||||||
reverseLayout = true
|
reverseLayout = true
|
||||||
) {
|
) {
|
||||||
@@ -647,7 +761,26 @@ fun ChatDetailScreen(
|
|||||||
MessageBubble(
|
MessageBubble(
|
||||||
message = message,
|
message = message,
|
||||||
isDarkTheme = isDarkTheme,
|
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(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.align(Alignment.BottomEnd)
|
Modifier.align(Alignment.BottomEnd)
|
||||||
.padding(end = 16.dp, bottom = 16.dp)
|
.padding(end = 16.dp, bottom = 80.dp)
|
||||||
.size(44.dp)
|
.size(44.dp)
|
||||||
.shadow(
|
.shadow(
|
||||||
elevation = 8.dp,
|
elevation = 8.dp,
|
||||||
@@ -728,33 +861,166 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 INPUT BAR - обычный элемент внизу Column
|
// 🔥 FLOATING INPUT BAR - плавает поверх сообщений, поднимается с клавиатурой
|
||||||
MessageInputBar(
|
// Скрываем когда в режиме выбора
|
||||||
value = inputText,
|
AnimatedVisibility(
|
||||||
onValueChange = {
|
visible = !isSelectionMode,
|
||||||
viewModel.updateInputText(it)
|
enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }),
|
||||||
// Отправляем индикатор печатания
|
exit = fadeOut(tween(150)) + slideOutVertically(targetOffsetY = { it }),
|
||||||
if (it.isNotEmpty() && !isSavedMessages) {
|
modifier = Modifier
|
||||||
viewModel.sendTypingIndicator()
|
.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 = {
|
// Forward button - стеклянная кнопка
|
||||||
// Скрываем кнопку scroll на время отправки
|
Box(
|
||||||
isSendingMessage = true
|
modifier = Modifier
|
||||||
viewModel.sendMessage()
|
.weight(1f)
|
||||||
// Скроллим к новому сообщению
|
.clip(RoundedCornerShape(14.dp))
|
||||||
scope.launch {
|
.background(
|
||||||
delay(100)
|
if (isDarkTheme) Color.White.copy(alpha = 0.08f)
|
||||||
listState.animateScrollToItem(0)
|
else Color.Black.copy(alpha = 0.04f)
|
||||||
delay(300) // Ждём завершения анимации
|
)
|
||||||
isSendingMessage = false
|
.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
|
} // Закрытие Box с fade-in
|
||||||
@@ -904,11 +1170,31 @@ fun rememberMessageEnterAnimation(messageId: String): Pair<Float, Float> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 🚀 Пузырек сообщения Telegram-style */
|
/** 🚀 Пузырек сообщения Telegram-style */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@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
|
// Telegram-style enter animation
|
||||||
val (alpha, translationY) = rememberMessageEnterAnimation(message.id)
|
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
|
// Telegram colors
|
||||||
val bubbleColor =
|
val bubbleColor =
|
||||||
if (message.isOutgoing) {
|
if (message.isOutgoing) {
|
||||||
@@ -943,15 +1229,42 @@ private fun MessageBubble(message: ChatMessage, isDarkTheme: Boolean, index: Int
|
|||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 1.dp)
|
.padding(horizontal = 8.dp, vertical = 1.dp)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
this.alpha = alpha
|
this.alpha = alpha * selectionAlpha
|
||||||
this.translationY = translationY
|
this.translationY = translationY
|
||||||
|
this.scaleX = selectionScale
|
||||||
|
this.scaleY = selectionScale
|
||||||
},
|
},
|
||||||
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
|
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(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.widthIn(max = 300.dp)
|
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)
|
.clip(bubbleShape)
|
||||||
.background(bubbleColor)
|
.background(bubbleColor)
|
||||||
.padding(horizontal = 12.dp, vertical = 7.dp)
|
.padding(horizontal = 12.dp, vertical = 7.dp)
|
||||||
@@ -1046,8 +1359,14 @@ private fun MessageInputBar(
|
|||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
backgroundColor: Color,
|
backgroundColor: Color,
|
||||||
textColor: 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) }
|
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||||
// Флаг для запуска закрытия клавиатуры перед открытием emoji picker
|
// Флаг для запуска закрытия клавиатуры перед открытием emoji picker
|
||||||
var pendingEmojiPicker by remember { mutableStateOf(false) }
|
var pendingEmojiPicker by remember { mutableStateOf(false) }
|
||||||
@@ -1111,12 +1430,13 @@ private fun MessageInputBar(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
// 🔥 TELEGRAM-STYLE FLOATING LIQUID GLASS INPUT
|
// 🔥 TELEGRAM-STYLE FLOATING LIQUID GLASS INPUT
|
||||||
Row(
|
// Используем Column вместо Row для reply panel внутри
|
||||||
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 16.dp)
|
.padding(horizontal = 8.dp, vertical = 16.dp)
|
||||||
// Инпут растёт вверх до 6 строк (~140dp)
|
// Инпут растёт вверх до 6 строк (~140dp) + reply
|
||||||
.heightIn(min = 44.dp, max = 140.dp)
|
.heightIn(min = 44.dp, max = if (hasReply) 200.dp else 140.dp)
|
||||||
.shadow(
|
.shadow(
|
||||||
elevation = 4.dp,
|
elevation = 4.dp,
|
||||||
shape = RoundedCornerShape(22.dp),
|
shape = RoundedCornerShape(22.dp),
|
||||||
@@ -1165,70 +1485,137 @@ private fun MessageInputBar(
|
|||||||
),
|
),
|
||||||
shape = RoundedCornerShape(22.dp)
|
shape = RoundedCornerShape(22.dp)
|
||||||
)
|
)
|
||||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.Bottom
|
|
||||||
) {
|
) {
|
||||||
// EMOJI BUTTON
|
// 🔥 REPLY PANEL внутри glass (как в React Native)
|
||||||
Box(
|
AnimatedVisibility(
|
||||||
modifier =
|
visible = hasReply,
|
||||||
Modifier.align(Alignment.Bottom)
|
enter = fadeIn(tween(150)) + expandVertically(),
|
||||||
.size(36.dp)
|
exit = fadeOut(tween(100)) + shrinkVertically()
|
||||||
.clip(CircleShape)
|
|
||||||
.clickable(
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = null,
|
|
||||||
onClick = { toggleEmojiPicker() }
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Row(
|
||||||
if (showEmojiPicker) Icons.Default.Keyboard
|
modifier = Modifier
|
||||||
else Icons.Default.SentimentSatisfiedAlt,
|
.fillMaxWidth()
|
||||||
contentDescription = "Emoji",
|
.padding(start = 12.dp, end = 6.dp, top = 10.dp, bottom = 4.dp),
|
||||||
tint =
|
verticalAlignment = Alignment.CenterVertically
|
||||||
if (showEmojiPicker) PrimaryBlue
|
) {
|
||||||
else {
|
// Вертикальная синяя линия (как в React Native)
|
||||||
if (isDarkTheme) Color.White.copy(alpha = 0.65f)
|
Box(
|
||||||
else Color.Black.copy(alpha = 0.55f)
|
modifier = Modifier
|
||||||
},
|
.width(3.dp)
|
||||||
modifier = Modifier.size(26.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
|
// Input Row
|
||||||
Box(
|
Row(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.weight(1f)
|
.fillMaxWidth()
|
||||||
.align(Alignment.CenterVertically)
|
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||||
.padding(horizontal = 4.dp),
|
verticalAlignment = Alignment.Bottom
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
) {
|
||||||
AppleEmojiTextField(
|
// EMOJI BUTTON
|
||||||
value = value,
|
Box(
|
||||||
onValueChange = { newValue -> onValueChange(newValue) },
|
modifier =
|
||||||
textColor = textColor,
|
Modifier.align(Alignment.Bottom)
|
||||||
textSize = 17f,
|
.size(36.dp)
|
||||||
hint = "Message",
|
.clip(CircleShape)
|
||||||
hintColor =
|
.clickable(
|
||||||
if (isDarkTheme) Color.White.copy(alpha = 0.35f)
|
interactionSource = interactionSource,
|
||||||
else Color.Black.copy(alpha = 0.35f),
|
indication = null,
|
||||||
modifier = Modifier.fillMaxWidth()
|
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
|
// TEXT INPUT
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.align(Alignment.Bottom)
|
Modifier.weight(1f)
|
||||||
.size(36.dp)
|
.align(Alignment.CenterVertically)
|
||||||
.clip(CircleShape)
|
.padding(horizontal = 4.dp),
|
||||||
.clickable(
|
contentAlignment = Alignment.CenterStart
|
||||||
interactionSource = interactionSource,
|
) {
|
||||||
indication = null
|
AppleEmojiTextField(
|
||||||
) { /* TODO: Attach */},
|
value = value,
|
||||||
contentAlignment = Alignment.Center
|
onValueChange = { newValue -> onValueChange(newValue) },
|
||||||
) {
|
textColor = textColor,
|
||||||
Icon(
|
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,
|
Icons.Default.Attachment,
|
||||||
contentDescription = "Attach",
|
contentDescription = "Attach",
|
||||||
tint =
|
tint =
|
||||||
@@ -1287,7 +1674,8 @@ private fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} // End of Input Row
|
||||||
|
} // End of Glass Column
|
||||||
|
|
||||||
// Apple Emoji Picker
|
// Apple Emoji Picker
|
||||||
AnimatedVisibility(
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private val _inputText = MutableStateFlow("")
|
private val _inputText = MutableStateFlow("")
|
||||||
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
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 currentOffset = 0
|
||||||
private var hasMoreMessages = true
|
private var hasMoreMessages = true
|
||||||
@@ -414,19 +427,63 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
_inputText.value = text
|
_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 (мгновенное отображение)
|
* - Optimistic UI (мгновенное отображение)
|
||||||
* - Шифрование в IO потоке
|
* - Шифрование в IO потоке
|
||||||
* - Сохранение в БД в IO потоке
|
* - Сохранение в БД в IO потоке
|
||||||
|
* - Поддержка Reply/Forward
|
||||||
*/
|
*/
|
||||||
fun sendMessage() {
|
fun sendMessage() {
|
||||||
val text = _inputText.value.trim()
|
val text = _inputText.value.trim()
|
||||||
val recipient = opponentKey
|
val recipient = opponentKey
|
||||||
val sender = myPublicKey
|
val sender = myPublicKey
|
||||||
val privateKey = myPrivateKey
|
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")
|
ProtocolManager.addLog("❌ Empty text")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -448,10 +505,28 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
val timestamp = System.currentTimeMillis()
|
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 - мгновенно показываем сообщение
|
// 1. 🚀 Optimistic UI - мгновенно показываем сообщение
|
||||||
val optimisticMessage = ChatMessage(
|
val optimisticMessage = ChatMessage(
|
||||||
id = messageId,
|
id = messageId,
|
||||||
text = text,
|
text = fullText,
|
||||||
isOutgoing = true,
|
isOutgoing = true,
|
||||||
timestamp = Date(timestamp),
|
timestamp = Date(timestamp),
|
||||||
status = MessageStatus.SENDING
|
status = MessageStatus.SENDING
|
||||||
@@ -459,16 +534,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
_messages.value = _messages.value + optimisticMessage
|
_messages.value = _messages.value + optimisticMessage
|
||||||
_inputText.value = ""
|
_inputText.value = ""
|
||||||
|
|
||||||
// Кэшируем текст
|
// Очищаем reply после отправки
|
||||||
decryptionCache[messageId] = text
|
clearReplyMessages()
|
||||||
|
|
||||||
ProtocolManager.addLog("📤 Sending: \"${text.take(20)}...\"")
|
// Кэшируем текст
|
||||||
|
decryptionCache[messageId] = fullText
|
||||||
|
|
||||||
|
ProtocolManager.addLog("📤 Sending: \"${fullText.take(20)}...\"")
|
||||||
|
|
||||||
// 2. 🔥 Шифрование и отправка в IO потоке
|
// 2. 🔥 Шифрование и отправка в IO потоке
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
// Шифрование (тяжёлая операция)
|
// Шифрование (тяжёлая операция)
|
||||||
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(text, recipient)
|
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(fullText, recipient)
|
||||||
|
|
||||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
@@ -494,7 +572,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// 4. 💾 Сохранение в БД (уже в IO потоке)
|
// 4. 💾 Сохранение в БД (уже в IO потоке)
|
||||||
saveMessageToDatabase(
|
saveMessageToDatabase(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
text = text,
|
text = fullText,
|
||||||
encryptedContent = encryptedContent,
|
encryptedContent = encryptedContent,
|
||||||
encryptedKey = encryptedKey,
|
encryptedKey = encryptedKey,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
@@ -502,7 +580,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
delivered = 1 // SENT - сервер принял
|
delivered = 1 // SENT - сервер принял
|
||||||
)
|
)
|
||||||
|
|
||||||
saveDialog(text, timestamp)
|
saveDialog(fullText, timestamp)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Send error", e)
|
Log.e(TAG, "Send error", e)
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import android.util.LruCache
|
|||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.inputmethod.EditorInfo
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
|
import android.widget.FrameLayout
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
@@ -213,14 +216,25 @@ fun AppleEmojiTextField(
|
|||||||
setHint(hint)
|
setHint(hint)
|
||||||
setTextSize(textSize)
|
setTextSize(textSize)
|
||||||
onTextChange = onValueChange
|
onTextChange = onValueChange
|
||||||
|
// Убираем все возможные фоны у EditText
|
||||||
|
background = null
|
||||||
|
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { view ->
|
update = { view ->
|
||||||
if (view.text.toString() != value) {
|
if (view.text.toString() != value) {
|
||||||
view.setTextWithEmojis(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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user