feat: Enhance packet sending logic to handle connection issues and implement swipe-to-reply functionality in chat UI
This commit is contained in:
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user