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:label="@string/app_name"
|
||||
android:theme="@style/Theme.RosettaAndroid"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:windowSoftInputMode="adjustNothing">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
|
||||
@@ -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) {
|
||||
// Автоматический reconnect (как в Архиве)
|
||||
if (!isManuallyClosed) {
|
||||
// Сбрасываем счетчик если до этого были подключены
|
||||
if (previousState == ProtocolState.AUTHENTICATED || previousState == ProtocolState.CONNECTED) {
|
||||
log("🔄 Connection lost, will reconnect...")
|
||||
}
|
||||
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++
|
||||
log("🔄 Reconnecting in ${RECONNECT_INTERVAL}ms (attempt $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS)")
|
||||
val delay = RECONNECT_INTERVAL * minOf(reconnectAttempts, 3) // Exponential backoff до 15 секунд
|
||||
log("🔄 Reconnecting in ${delay}ms (attempt $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS)")
|
||||
|
||||
scope.launch {
|
||||
delay(RECONNECT_INTERVAL)
|
||||
delay(delay)
|
||||
if (!isManuallyClosed) {
|
||||
connect()
|
||||
}
|
||||
} else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
log("❌ Max reconnect attempts reached")
|
||||
_lastError.value = "Unable to connect to server"
|
||||
}
|
||||
} else {
|
||||
log("❌ Max reconnect attempts reached, resetting counter...")
|
||||
// Сбрасываем счетчик и пробуем снова через большой интервал
|
||||
reconnectAttempts = 0
|
||||
scope.launch {
|
||||
delay(30000) // 30 секунд перед следующей серией попыток
|
||||
if (!isManuallyClosed) {
|
||||
log("🔄 Restarting reconnection attempts...")
|
||||
connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
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,13 +523,16 @@ fun ChatDetailScreen(
|
||||
VerifiedBadge(verified = user.verified, size = 16)
|
||||
}
|
||||
}
|
||||
// Typing indicator или subtitle
|
||||
if (isTyping) {
|
||||
TypingIndicator(isDarkTheme = isDarkTheme)
|
||||
} else {
|
||||
Text(
|
||||
text = chatSubtitle,
|
||||
fontSize = 13.sp,
|
||||
color =
|
||||
when {
|
||||
isSavedMessages -> secondaryTextColor
|
||||
isTyping -> PrimaryBlue // Синий когда печатает
|
||||
isOnline ->
|
||||
Color(
|
||||
0xFF38B24D
|
||||
@@ -429,6 +542,7 @@ fun ChatDetailScreen(
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопки действий
|
||||
if (!isSavedMessages) {
|
||||
@@ -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,7 +861,18 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 INPUT BAR - обычный элемент внизу Column
|
||||
// 🔥 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 = {
|
||||
@@ -753,9 +897,131 @@ fun ChatDetailScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
backgroundColor = inputBackgroundColor,
|
||||
textColor = textColor,
|
||||
placeholderColor = secondaryTextColor
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Закрытие 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,6 +1485,73 @@ private fun MessageInputBar(
|
||||
),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
)
|
||||
) {
|
||||
// 🔥 REPLY PANEL внутри glass (как в React Native)
|
||||
AnimatedVisibility(
|
||||
visible = hasReply,
|
||||
enter = fadeIn(tween(150)) + expandVertically(),
|
||||
exit = fadeOut(tween(100)) + shrinkVertically()
|
||||
) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input Row
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user