feat: Enhance packet sending logic to handle connection issues and implement swipe-to-reply functionality in chat UI

This commit is contained in:
k1ngsterr1
2026-01-13 06:32:16 +05:00
parent 686adc1af2
commit 5bb9560353
5 changed files with 174 additions and 42 deletions

View File

@@ -9,8 +9,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -44,6 +48,9 @@ import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -230,14 +237,18 @@ fun ChatDetailScreen(
val replyMessages by viewModel.replyMessages.collectAsState()
val hasReply = replyMessages.isNotEmpty()
// 🔥 FocusRequester для автофокуса на инпут при reply
val inputFocusRequester = remember { FocusRequester() }
// 🔥 Дополнительная высота для reply панели (~50dp)
val replyPanelHeight = if (hasReply) 50.dp else 0.dp
// Динамический bottom padding для списка: инпут (~70dp) + reply (~50dp) + клавиатура/эмодзи
// Одинаковый базовый отступ 70.dp для всех состояний
val listBottomPadding = when {
isKeyboardVisible -> 70.dp + replyPanelHeight + imeHeight
showEmojiPicker -> 70.dp + replyPanelHeight + emojiPanelHeight
else -> 100.dp + replyPanelHeight
else -> 70.dp + replyPanelHeight // Было 100.dp, теперь одинаково для всех состояний
}
// Telegram-style scroll tracking
@@ -861,6 +872,10 @@ fun ChatDetailScreen(
selectedMessages + selectionKey
}
}
},
onSwipeToReply = {
// 🔥 Swipe-to-reply: добавляем это сообщение в reply
viewModel.setReplyMessages(listOf(message))
}
)
}
@@ -994,7 +1009,9 @@ fun ChatDetailScreen(
isBlocked = isBlocked,
// Emoji picker state (поднят для KeyboardAvoidingView)
showEmojiPicker = showEmojiPicker,
onToggleEmojiPicker = { showEmojiPicker = it }
onToggleEmojiPicker = { showEmojiPicker = it },
// Focus requester для автофокуса при reply
focusRequester = inputFocusRequester
)
}
@@ -1007,7 +1024,7 @@ fun ChatDetailScreen(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.imePadding()
.windowInsetsPadding(WindowInsets.ime.only(WindowInsetsSides.Bottom))
) {
// Плоский контейнер как у инпута
Column(
@@ -1360,8 +1377,24 @@ private fun MessageBubble(
showTail: Boolean = true,
isSelected: Boolean = false,
onLongClick: () -> Unit = {},
onClick: () -> Unit = {}
onClick: () -> Unit = {},
onSwipeToReply: () -> Unit = {}
) {
// 🔥 Swipe-to-reply state (как в Telegram)
var swipeOffset by remember { mutableStateOf(0f) }
val swipeThreshold = 80f // dp порог для активации reply
val maxSwipe = 120f // Максимальный сдвиг
// Анимация возврата
val animatedOffset by animateFloatAsState(
targetValue = swipeOffset,
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
label = "swipeOffset"
)
// Прогресс свайпа для иконки reply (0..1) - используем абсолютное значение т.к. свайп влево
val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
// ❌ УБРАЛИ: Telegram-style enter animation - она мешает при скролле
// val (alpha, translationY) = rememberMessageEnterAnimation(message.id)
@@ -1406,11 +1439,70 @@ private fun MessageBubble(
)
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
// 🔥 Swipe-to-reply wrapper
Box(
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
// Если свайп достиг порога - активируем reply
if (swipeOffset <= -swipeThreshold) {
onSwipeToReply()
}
// Возвращаем на место
swipeOffset = 0f
},
onDragCancel = {
swipeOffset = 0f
},
onHorizontalDrag = { _, dragAmount ->
// Только свайп влево (отрицательный dragAmount)
val newOffset = swipeOffset + dragAmount
swipeOffset = newOffset.coerceIn(-maxSwipe, 0f)
}
)
}
) {
// 🔥 Reply icon (появляется справа при свайпе влево)
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 16.dp)
.graphicsLayer {
alpha = swipeProgress
scaleX = swipeProgress
scaleY = swipeProgress
}
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(
if (swipeProgress >= 1f) PrimaryBlue
else if (isDarkTheme) Color(0xFF3A3A3A)
else Color(0xFFE0E0E0)
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Reply,
contentDescription = "Reply",
tint = if (swipeProgress >= 1f) Color.White
else if (isDarkTheme) Color.White.copy(alpha = 0.7f)
else Color(0xFF666666),
modifier = Modifier.size(20.dp)
)
}
}
Row(
Row(
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 1.dp)
.offset { IntOffset(animatedOffset.toInt(), 0) }
.graphicsLayer {
// ❌ УБРАЛИ: alpha = alpha * selectionAlpha и translationY
// Оставляем только selection анимацию
@@ -1419,7 +1511,7 @@ private fun MessageBubble(
this.scaleY = selectionScale
},
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
) {
) {
// Checkbox для выбранных сообщений
AnimatedVisibility(
visible = isSelected,
@@ -1449,8 +1541,6 @@ private fun MessageBubble(
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)
@@ -1488,6 +1578,7 @@ private fun MessageBubble(
}
}
}
} // End of swipe Box wrapper
}
/**
@@ -1691,7 +1782,9 @@ private fun MessageInputBar(
isBlocked: Boolean = false,
// Emoji picker state (поднят для KeyboardAvoidingView)
showEmojiPicker: Boolean = false,
onToggleEmojiPicker: (Boolean) -> Unit = {}
onToggleEmojiPicker: (Boolean) -> Unit = {},
// Focus requester для автофокуса при reply
focusRequester: FocusRequester? = null
) {
val hasReply = replyMessages.isNotEmpty()
val keyboardController = LocalSoftwareKeyboardController.current
@@ -1703,6 +1796,23 @@ private fun MessageInputBar(
val context = LocalContext.current
val view = LocalView.current
val density = LocalDensity.current
// 🔥 Ссылка на EditText для программного фокуса
var editTextView by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiEditTextView?>(null) }
// 🔥 Автофокус при открытии reply панели
LaunchedEffect(hasReply, editTextView) {
if (hasReply) {
// Даём время на создание view если ещё null
kotlinx.coroutines.delay(50)
editTextView?.let { editText ->
editText.requestFocus()
// Открываем клавиатуру
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
}
}
// 🔥 Отслеживаем высоту клавиатуры (Telegram-style)
val imeInsets = WindowInsets.ime
@@ -1739,11 +1849,8 @@ private fun MessageInputBar(
// Закрываем клавиатуру через IMM
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
// Небольшая задержка перед показом эмодзи для плавного перехода
scope.launch {
delay(50)
onToggleEmojiPicker(true)
}
// Показываем эмодзи сразу без задержки (эмодзи уже предзагружены)
onToggleEmojiPicker(true)
}
}
@@ -1760,7 +1867,7 @@ private fun MessageInputBar(
Column(
modifier = Modifier
.fillMaxWidth()
.imePadding()
.windowInsetsPadding(WindowInsets.ime.only(WindowInsetsSides.Bottom))
) {
// Если пользователь заблокирован - показываем BlockedChatFooter (плоский как инпут)
if (isBlocked) {
@@ -1992,8 +2099,8 @@ private fun MessageInputBar(
// Мгновенно когда клавиатура открывается
snap()
} else {
// Плавно когда открываем/закрываем эмодзи без клавиатуры
tween(durationMillis = 200, easing = FastOutSlowInEasing)
// Быстрая анимация для мгновенного отклика (как в Telegram)
tween(durationMillis = 150, easing = TelegramEasing)
},
label = "EmojiPanelHeight"
)
@@ -2004,7 +2111,8 @@ private fun MessageInputBar(
.height(animatedHeight)
.clipToBounds()
) {
if (showEmojiPicker && !isKeyboardVisible) {
// 🚀 Рендерим панель только когда нужно
if (showEmojiPicker && !isKeyboardVisible && animatedHeight > 0.dp) {
AppleEmojiPickerPanel(
isDarkTheme = isDarkTheme,
onEmojiSelected = { emoji ->

View File

@@ -876,12 +876,11 @@ fun DialogItem(dialog: DialogEntity, isDarkTheme: Boolean, onClick: () -> Unit)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
// 🔥 Используем AppleEmojiText для отображения эмодзи
AppleEmojiText(
text = dialog.lastMessage.ifEmpty { "No messages" },
fontSize = 14.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)