feat: Improve keyboard handling in selection mode for better user experience
This commit is contained in:
@@ -323,15 +323,22 @@ fun ChatDetailScreen(
|
|||||||
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
val isSelectionMode = selectedMessages.isNotEmpty()
|
val isSelectionMode = selectedMessages.isNotEmpty()
|
||||||
|
|
||||||
// 🔥 Закрываем клавиатуру и emoji picker когда открывается selection mode (action bar с Reply/Forward)
|
// Логирование изменений selection mode
|
||||||
|
LaunchedEffect(isSelectionMode, selectedMessages.size) {
|
||||||
|
android.util.Log.d("ChatDetailScreen", "═══════════════════════════════════════")
|
||||||
|
android.util.Log.d("ChatDetailScreen", "📝 SELECTION MODE CHANGED")
|
||||||
|
android.util.Log.d("ChatDetailScreen", " 📊 isSelectionMode: $isSelectionMode")
|
||||||
|
android.util.Log.d("ChatDetailScreen", " 📊 selectedMessages.size: ${selectedMessages.size}")
|
||||||
|
android.util.Log.d("ChatDetailScreen", "═══════════════════════════════════════")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Backup: если клавиатура ещё открыта когда selection mode активировался
|
||||||
|
// (клавиатура уже должна быть закрыта в onLongClick, это только backup)
|
||||||
LaunchedEffect(isSelectionMode) {
|
LaunchedEffect(isSelectionMode) {
|
||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
// Используем нативный InputMethodManager для НАДЁЖНОГО закрытия клавиатуры
|
android.util.Log.d("ChatDetailScreen", "⚠️ Backup keyboard hide triggered")
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
// Backup закрытие клавиатуры (основное в onLongClick)
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
|
||||||
keyboardController?.hide()
|
keyboardController?.hide()
|
||||||
focusManager.clearFocus()
|
|
||||||
showEmojiPicker = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,164 +549,140 @@ fun ChatDetailScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets(0.dp),
|
contentWindowInsets = WindowInsets(0.dp),
|
||||||
topBar = {
|
topBar = {
|
||||||
// 🔥 SELECTION HEADER (появляется при выборе сообщений) - Telegram Style
|
// 🔥 UNIFIED HEADER - один контейнер, контент меняется внутри
|
||||||
AnimatedVisibility(
|
Box(
|
||||||
visible = isSelectionMode,
|
modifier = Modifier
|
||||||
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()
|
.fillMaxWidth()
|
||||||
.background(if (isDarkTheme) Color(0xFF212121) else Color.White)
|
.background(if (isSelectionMode) {
|
||||||
) {
|
if (isDarkTheme) Color(0xFF212121) else Color.White
|
||||||
Row(
|
} else headerBackground)
|
||||||
modifier = Modifier
|
) {
|
||||||
.fillMaxWidth()
|
// Контент хедера с Crossfade для плавной смены
|
||||||
.statusBarsPadding()
|
Crossfade(
|
||||||
.height(56.dp)
|
targetState = isSelectionMode,
|
||||||
.padding(horizontal = 4.dp),
|
animationSpec = tween(200),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
label = "headerContent"
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
) { selectionMode ->
|
||||||
) {
|
if (selectionMode) {
|
||||||
// Left: X (cancel) + Count - Telegram Style
|
// SELECTION MODE CONTENT
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
IconButton(onClick = { selectedMessages = emptySet() }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = "Cancel",
|
|
||||||
tint = if (isDarkTheme) Color.White else Color.Black,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
"${selectedMessages.size}",
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right: Action buttons - Telegram Style
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
modifier = Modifier
|
||||||
verticalAlignment = Alignment.CenterVertically
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.height(56.dp)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
// Copy button
|
// Left: X (cancel) + Count
|
||||||
IconButton(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
onClick = {
|
IconButton(onClick = { selectedMessages = emptySet() }) {
|
||||||
val textToCopy = messages
|
Icon(
|
||||||
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
|
Icons.Default.Close,
|
||||||
.sortedBy { it.timestamp }
|
contentDescription = "Cancel",
|
||||||
.joinToString("\n\n") { msg ->
|
tint = if (isDarkTheme) Color.White else Color.Black,
|
||||||
val time = SimpleDateFormat("HH:mm", Locale.getDefault())
|
modifier = Modifier.size(24.dp)
|
||||||
.format(msg.timestamp)
|
)
|
||||||
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}"
|
|
||||||
}
|
|
||||||
clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(textToCopy))
|
|
||||||
selectedMessages = emptySet()
|
|
||||||
}
|
}
|
||||||
) {
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Icon(
|
Text(
|
||||||
Icons.Default.ContentCopy,
|
"${selectedMessages.size}",
|
||||||
contentDescription = "Copy",
|
fontSize = 20.sp,
|
||||||
tint = if (isDarkTheme) Color.White else Color.Black,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.size(22.dp)
|
color = if (isDarkTheme) Color.White else Color.Black
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete button
|
// Right: Action buttons
|
||||||
IconButton(
|
Row(
|
||||||
onClick = {
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
// Удаляем выбранные сообщения
|
verticalAlignment = Alignment.CenterVertically
|
||||||
messages
|
) {
|
||||||
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
|
// Copy button
|
||||||
.forEach { msg -> viewModel.deleteMessage(msg.id) }
|
IconButton(
|
||||||
selectedMessages = emptySet()
|
onClick = {
|
||||||
|
val textToCopy = messages
|
||||||
|
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
|
||||||
|
.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 = if (isDarkTheme) Color.White else Color.Black,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
messages
|
||||||
|
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
|
||||||
|
.forEach { msg -> viewModel.deleteMessage(msg.id) }
|
||||||
|
selectedMessages = emptySet()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = "Delete",
|
||||||
|
tint = if (isDarkTheme) Color.White else Color.Black,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = "Delete",
|
|
||||||
tint = if (isDarkTheme) Color.White else Color.Black,
|
|
||||||
modifier = Modifier.size(22.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
// NORMAL HEADER CONTENT
|
||||||
// Bottom line
|
Row(
|
||||||
Box(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter)
|
.statusBarsPadding()
|
||||||
.fillMaxWidth()
|
.height(56.dp)
|
||||||
.height(0.5.dp)
|
.padding(horizontal = 4.dp),
|
||||||
.background(
|
verticalAlignment = Alignment.CenterVertically
|
||||||
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(
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.statusBarsPadding()
|
|
||||||
.height(56.dp)
|
|
||||||
.padding(horizontal = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// 🔔 Кнопка назад с badge непрочитанных сообщений
|
|
||||||
Box {
|
|
||||||
IconButton(
|
|
||||||
onClick = hideKeyboardAndBack,
|
|
||||||
modifier = Modifier.size(40.dp)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
// Back button with badge
|
||||||
Icons.Default.KeyboardArrowLeft,
|
Box {
|
||||||
contentDescription = "Back",
|
IconButton(
|
||||||
tint = headerIconColor,
|
onClick = hideKeyboardAndBack,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(40.dp)
|
||||||
)
|
) {
|
||||||
}
|
Icon(
|
||||||
// Badge с количеством непрочитанных из других чатов
|
Icons.Default.KeyboardArrowLeft,
|
||||||
if (totalUnreadFromOthers > 0) {
|
contentDescription = "Back",
|
||||||
Box(
|
tint = headerIconColor,
|
||||||
modifier = Modifier
|
modifier = Modifier.size(32.dp)
|
||||||
.align(Alignment.TopEnd)
|
)
|
||||||
.offset(x = (-4).dp, y = 6.dp)
|
}
|
||||||
.size(if (totalUnreadFromOthers > 9) 20.dp else 18.dp)
|
if (totalUnreadFromOthers > 0) {
|
||||||
.clip(CircleShape)
|
Box(
|
||||||
.background(Color(0xFFFF3B30)), // Красный цвет как в iOS
|
modifier = Modifier
|
||||||
contentAlignment = Alignment.Center
|
.align(Alignment.TopEnd)
|
||||||
) {
|
.offset(x = (-4).dp, y = 6.dp)
|
||||||
Text(
|
.size(if (totalUnreadFromOthers > 9) 20.dp else 18.dp)
|
||||||
text = if (totalUnreadFromOthers > 99) "99+"
|
.clip(CircleShape)
|
||||||
else if (totalUnreadFromOthers > 9) "$totalUnreadFromOthers"
|
.background(Color(0xFFFF3B30)),
|
||||||
else "$totalUnreadFromOthers",
|
contentAlignment = Alignment.Center
|
||||||
color = Color.White,
|
) {
|
||||||
fontSize = if (totalUnreadFromOthers > 9) 9.sp else 10.sp,
|
Text(
|
||||||
fontWeight = FontWeight.Bold,
|
text = if (totalUnreadFromOthers > 99) "99+"
|
||||||
maxLines = 1
|
else "$totalUnreadFromOthers",
|
||||||
)
|
color = Color.White,
|
||||||
|
fontSize = if (totalUnreadFromOthers > 9) 9.sp else 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
@@ -962,20 +945,21 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Нижняя линия для разделения
|
}
|
||||||
|
} // Закрытие Crossfade
|
||||||
|
|
||||||
|
// Bottom line для unified header
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(0.5.dp)
|
.height(0.5.dp)
|
||||||
.background(
|
.background(
|
||||||
if (isDarkTheme)
|
if (isDarkTheme) Color.White.copy(alpha = 0.15f)
|
||||||
Color.White.copy(alpha = 0.1f)
|
else Color.Black.copy(alpha = 0.1f)
|
||||||
else Color.Black.copy(alpha = 0.08f)
|
)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
} // Закрытие Box unified header
|
||||||
} // Закрытие AnimatedVisibility для normal header
|
|
||||||
},
|
},
|
||||||
containerColor = backgroundColor, // Фон всего чата
|
containerColor = backgroundColor, // Фон всего чата
|
||||||
// 🔥 Bottom bar - инпут с умным padding:
|
// 🔥 Bottom bar - инпут с умным padding:
|
||||||
@@ -984,76 +968,38 @@ fun ChatDetailScreen(
|
|||||||
bottomBar = {
|
bottomBar = {
|
||||||
// 🔥 Telegram-style: когда Box с эмодзи виден, НЕ используем imePadding
|
// 🔥 Telegram-style: когда Box с эмодзи виден, НЕ используем imePadding
|
||||||
// isEmojiBoxVisible учитывает анимацию fade-out (alpha > 0.01)
|
// isEmojiBoxVisible учитывает анимацию fade-out (alpha > 0.01)
|
||||||
val bottomModifier = if (coordinator.isEmojiBoxVisible) {
|
// 🔥 В selection mode НЕ используем imePadding (клавиатура закрыта)
|
||||||
Modifier // Без imePadding - Box с эмодзи заменяет клавиатуру
|
val useImePadding = !coordinator.isEmojiBoxVisible && !isSelectionMode
|
||||||
} else {
|
val bottomModifier = if (useImePadding) {
|
||||||
Modifier.imePadding() // С imePadding - клавиатура поднимает инпут
|
Modifier.imePadding() // С imePadding - клавиатура поднимает инпут
|
||||||
}
|
} else {
|
||||||
Column(modifier = bottomModifier) {
|
Modifier // Без imePadding
|
||||||
// 🔥 FLOATING INPUT BAR - плавает поверх сообщений, поднимается с клавиатурой
|
|
||||||
// Скрываем когда в режиме выбора
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = !isSelectionMode,
|
|
||||||
enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }),
|
|
||||||
exit = fadeOut(tween(150)) + slideOutVertically(targetOffsetY = { it })
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
// 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 = backgroundColor, // Тот же цвет что и фон чата
|
|
||||||
textColor = textColor,
|
|
||||||
placeholderColor = secondaryTextColor,
|
|
||||||
secondaryTextColor = secondaryTextColor,
|
|
||||||
// Reply state
|
|
||||||
replyMessages = replyMessages,
|
|
||||||
isForwardMode = isForwardMode,
|
|
||||||
onCloseReply = { viewModel.clearReplyMessages() },
|
|
||||||
chatTitle = chatTitle,
|
|
||||||
isBlocked = isBlocked,
|
|
||||||
// Emoji picker state (поднят для KeyboardAvoidingView)
|
|
||||||
showEmojiPicker = showEmojiPicker,
|
|
||||||
onToggleEmojiPicker = { showEmojiPicker = it },
|
|
||||||
// Focus requester для автофокуса при reply
|
|
||||||
focusRequester = inputFocusRequester,
|
|
||||||
// Coordinator для плавных переходов
|
|
||||||
coordinator = coordinator,
|
|
||||||
// 🔥 Для отображения reply preview и скролла
|
|
||||||
displayReplyMessages = displayReplyMessages,
|
|
||||||
onReplyClick = scrollToMessage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 SELECTION ACTION BAR - Reply/Forward (появляется при выборе сообщений)
|
// Логирование состояния
|
||||||
// Плоский стиль как у инпута с border сверху
|
LaunchedEffect(isSelectionMode, useImePadding, coordinator.isEmojiBoxVisible, coordinator.keyboardHeight) {
|
||||||
AnimatedVisibility(
|
android.util.Log.d("ChatDetailScreen", "═══════════════════════════════════════")
|
||||||
visible = isSelectionMode,
|
android.util.Log.d("ChatDetailScreen", "🔄 BOTTOM BAR STATE CHANGED")
|
||||||
enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }),
|
android.util.Log.d("ChatDetailScreen", " 📊 isSelectionMode: $isSelectionMode")
|
||||||
exit = fadeOut(tween(150)) + slideOutVertically(targetOffsetY = { it })
|
android.util.Log.d("ChatDetailScreen", " 📊 useImePadding: $useImePadding")
|
||||||
) {
|
android.util.Log.d("ChatDetailScreen", " 📊 isEmojiBoxVisible: ${coordinator.isEmojiBoxVisible}")
|
||||||
Column {
|
android.util.Log.d("ChatDetailScreen", " 📊 keyboardHeight: ${coordinator.keyboardHeight}")
|
||||||
// Плоский контейнер как у инпута
|
android.util.Log.d("ChatDetailScreen", " 📊 emojiHeight: ${coordinator.emojiHeight}")
|
||||||
|
android.util.Log.d("ChatDetailScreen", "═══════════════════════════════════════")
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = bottomModifier) {
|
||||||
|
// 🔥 UNIFIED BOTTOM BAR - один контейнер, контент меняется внутри
|
||||||
|
Crossfade(
|
||||||
|
targetState = isSelectionMode,
|
||||||
|
animationSpec = tween(200),
|
||||||
|
label = "bottomBarContent"
|
||||||
|
) { selectionMode ->
|
||||||
|
android.util.Log.d("ChatDetailScreen", "🎬 Crossfade to selectionMode=$selectionMode")
|
||||||
|
|
||||||
|
if (selectionMode) {
|
||||||
|
// SELECTION ACTION BAR - Reply/Forward
|
||||||
|
// 🔥 Высота должна совпадать с MessageInputBar (~56dp content + nav bar)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1067,12 +1013,12 @@ fun ChatDetailScreen(
|
|||||||
.background(if (isDarkTheme) Color.White.copy(alpha = 0.15f) else Color.Black.copy(alpha = 0.1f))
|
.background(if (isDarkTheme) Color.White.copy(alpha = 0.15f) else Color.Black.copy(alpha = 0.1f))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Кнопки Reply и Forward
|
// Кнопки Reply и Forward - такие же отступы как у input row
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
.padding(bottom = 12.dp)
|
.padding(bottom = 16.dp)
|
||||||
.navigationBarsPadding(),
|
.navigationBarsPadding(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -1081,6 +1027,7 @@ fun ChatDetailScreen(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
.height(40.dp)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(PrimaryBlue.copy(alpha = 0.1f))
|
.background(PrimaryBlue.copy(alpha = 0.1f))
|
||||||
.clickable {
|
.clickable {
|
||||||
@@ -1089,8 +1036,7 @@ fun ChatDetailScreen(
|
|||||||
.sortedBy { it.timestamp }
|
.sortedBy { it.timestamp }
|
||||||
viewModel.setReplyMessages(selectedMsgs)
|
viewModel.setReplyMessages(selectedMsgs)
|
||||||
selectedMessages = emptySet()
|
selectedMessages = emptySet()
|
||||||
}
|
},
|
||||||
.padding(vertical = 16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -1117,6 +1063,7 @@ fun ChatDetailScreen(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
.height(40.dp)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(PrimaryBlue.copy(alpha = 0.1f))
|
.background(PrimaryBlue.copy(alpha = 0.1f))
|
||||||
.clickable {
|
.clickable {
|
||||||
@@ -1125,8 +1072,7 @@ fun ChatDetailScreen(
|
|||||||
.sortedBy { it.timestamp }
|
.sortedBy { it.timestamp }
|
||||||
viewModel.setForwardMessages(selectedMsgs)
|
viewModel.setForwardMessages(selectedMsgs)
|
||||||
selectedMessages = emptySet()
|
selectedMessages = emptySet()
|
||||||
}
|
},
|
||||||
.padding(vertical = 16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -1150,6 +1096,45 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// INPUT BAR
|
||||||
|
Column {
|
||||||
|
MessageInputBar(
|
||||||
|
value = inputText,
|
||||||
|
onValueChange = {
|
||||||
|
viewModel.updateInputText(it)
|
||||||
|
if (it.isNotEmpty() && !isSavedMessages) {
|
||||||
|
viewModel.sendTypingIndicator()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSend = {
|
||||||
|
isSendingMessage = true
|
||||||
|
viewModel.sendMessage()
|
||||||
|
scope.launch {
|
||||||
|
delay(100)
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
delay(300)
|
||||||
|
isSendingMessage = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
backgroundColor = backgroundColor,
|
||||||
|
textColor = textColor,
|
||||||
|
placeholderColor = secondaryTextColor,
|
||||||
|
secondaryTextColor = secondaryTextColor,
|
||||||
|
replyMessages = replyMessages,
|
||||||
|
isForwardMode = isForwardMode,
|
||||||
|
onCloseReply = { viewModel.clearReplyMessages() },
|
||||||
|
chatTitle = chatTitle,
|
||||||
|
isBlocked = isBlocked,
|
||||||
|
showEmojiPicker = showEmojiPicker,
|
||||||
|
onToggleEmojiPicker = { showEmojiPicker = it },
|
||||||
|
focusRequester = inputFocusRequester,
|
||||||
|
coordinator = coordinator,
|
||||||
|
displayReplyMessages = displayReplyMessages,
|
||||||
|
onReplyClick = scrollToMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // Закрытие Column с imePadding
|
} // Закрытие Column с imePadding
|
||||||
@@ -1288,12 +1273,29 @@ fun ChatDetailScreen(
|
|||||||
isSelected = selectedMessages.contains(selectionKey),
|
isSelected = selectedMessages.contains(selectionKey),
|
||||||
isHighlighted = highlightedMessageId == message.id,
|
isHighlighted = highlightedMessageId == message.id,
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
|
android.util.Log.d("ChatDetailScreen", "═══════════════════════════════════════")
|
||||||
|
android.util.Log.d("ChatDetailScreen", "👆 LONG CLICK on message")
|
||||||
|
android.util.Log.d("ChatDetailScreen", " 📊 isSelectionMode BEFORE: $isSelectionMode")
|
||||||
|
android.util.Log.d("ChatDetailScreen", " 📊 selectedMessages.size BEFORE: ${selectedMessages.size}")
|
||||||
|
|
||||||
|
// 🔥 СНАЧАЛА закрываем клавиатуру МГНОВЕННО (до изменения state)
|
||||||
|
if (!isSelectionMode) {
|
||||||
|
android.util.Log.d("ChatDetailScreen", " ⌨️ Closing keyboard...")
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
focusManager.clearFocus()
|
||||||
|
showEmojiPicker = false
|
||||||
|
android.util.Log.d("ChatDetailScreen", " ✅ Keyboard closed")
|
||||||
|
}
|
||||||
// Toggle selection on long press
|
// Toggle selection on long press
|
||||||
selectedMessages = if (selectedMessages.contains(selectionKey)) {
|
selectedMessages = if (selectedMessages.contains(selectionKey)) {
|
||||||
selectedMessages - selectionKey
|
selectedMessages - selectionKey
|
||||||
} else {
|
} else {
|
||||||
selectedMessages + selectionKey
|
selectedMessages + selectionKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("ChatDetailScreen", " 📊 selectedMessages.size AFTER: ${selectedMessages.size}")
|
||||||
|
android.util.Log.d("ChatDetailScreen", "═══════════════════════════════════════")
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
// If in selection mode, toggle selection
|
// If in selection mode, toggle selection
|
||||||
@@ -2332,11 +2334,14 @@ private fun MessageInputBar(
|
|||||||
.background(if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f))
|
.background(if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BLOCKED CHAT FOOTER - плоский стиль
|
||||||
|
// 🔥 Высота должна совпадать с MessageInputBar и Selection Action Bar
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
.padding(bottom = 16.dp),
|
.padding(bottom = 16.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
@@ -2453,11 +2458,14 @@ private fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// INPUT ROW - Paperclip → TextField → Emoji → Send/Mic (ПЛОСКИЙ ДИЗАЙН)
|
// INPUT ROW - Paperclip → TextField → Emoji → Send/Mic (ПЛОСКИЙ ДИЗАЙН)
|
||||||
|
// 🔥 Высота должна совпадать с Selection Action Bar (padding + nav bar)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 48.dp)
|
.heightIn(min = 48.dp)
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
.padding(bottom = 16.dp) // 🔥 Такой же отступ как у Selection Action Bar
|
||||||
|
.navigationBarsPadding(), // 🔥 Такой же navigationBarsPadding
|
||||||
verticalAlignment = Alignment.Bottom
|
verticalAlignment = Alignment.Bottom
|
||||||
) {
|
) {
|
||||||
// PAPERCLIP BUTTON (слева)
|
// PAPERCLIP BUTTON (слева)
|
||||||
|
|||||||
@@ -768,8 +768,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
* 🔥 Очистить reply/forward
|
* 🔥 Очистить reply/forward
|
||||||
*/
|
*/
|
||||||
fun clearReplyMessages() {
|
fun clearReplyMessages() {
|
||||||
_replyMessages.value = emptyList()
|
viewModelScope.launch {
|
||||||
_isForwardMode.value = false
|
delay(350) // Задержка после закрытия панели (анимация fadeOut + shrinkVertically)
|
||||||
|
_replyMessages.value = emptyList()
|
||||||
|
_isForwardMode.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
object KeyboardHeightProvider {
|
object KeyboardHeightProvider {
|
||||||
|
private const val TAG = "KeyboardHeight"
|
||||||
private const val PREFS_NAME = "emoji_keyboard_prefs"
|
private const val PREFS_NAME = "emoji_keyboard_prefs"
|
||||||
private const val KEY_KEYBOARD_HEIGHT = "kbd_height"
|
private const val KEY_KEYBOARD_HEIGHT = "kbd_height"
|
||||||
private const val DEFAULT_HEIGHT_DP = 280 // Telegram uses 200, we use 280
|
private const val DEFAULT_HEIGHT_DP = 280 // Telegram uses 200, we use 280
|
||||||
@@ -35,7 +36,11 @@ object KeyboardHeightProvider {
|
|||||||
val savedPx = prefs.getInt(KEY_KEYBOARD_HEIGHT, defaultPx)
|
val savedPx = prefs.getInt(KEY_KEYBOARD_HEIGHT, defaultPx)
|
||||||
val isDefault = savedPx == defaultPx
|
val isDefault = savedPx == defaultPx
|
||||||
|
|
||||||
android.util.Log.d("KeyboardHeight", "📖 getSavedKeyboardHeight: ${savedPx}px (${pxToDp(context, savedPx)}dp) ${if (isDefault) "[DEFAULT]" else "[SAVED]"}")
|
android.util.Log.d(TAG, "═══════════════════════════════════════")
|
||||||
|
android.util.Log.d(TAG, "📖 getSavedKeyboardHeight()")
|
||||||
|
android.util.Log.d(TAG, " 📏 Height: ${savedPx}px (${pxToDp(context, savedPx)}dp)")
|
||||||
|
android.util.Log.d(TAG, " 📦 Source: ${if (isDefault) "DEFAULT" else "SAVED"}")
|
||||||
|
android.util.Log.d(TAG, "═══════════════════════════════════════")
|
||||||
return savedPx
|
return savedPx
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +48,9 @@ object KeyboardHeightProvider {
|
|||||||
* Сохранить высоту клавиатуры
|
* Сохранить высоту клавиатуры
|
||||||
*/
|
*/
|
||||||
fun saveKeyboardHeight(context: Context, heightPx: Int) {
|
fun saveKeyboardHeight(context: Context, heightPx: Int) {
|
||||||
|
android.util.Log.d(TAG, "═══════════════════════════════════════")
|
||||||
|
android.util.Log.d(TAG, "💾 saveKeyboardHeight($heightPx px)")
|
||||||
|
|
||||||
if (heightPx > 0) {
|
if (heightPx > 0) {
|
||||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val oldHeight = prefs.getInt(KEY_KEYBOARD_HEIGHT, -1)
|
val oldHeight = prefs.getInt(KEY_KEYBOARD_HEIGHT, -1)
|
||||||
@@ -50,21 +58,29 @@ object KeyboardHeightProvider {
|
|||||||
|
|
||||||
prefs.edit().putInt(KEY_KEYBOARD_HEIGHT, heightPx).apply()
|
prefs.edit().putInt(KEY_KEYBOARD_HEIGHT, heightPx).apply()
|
||||||
|
|
||||||
|
android.util.Log.d(TAG, " 📏 New: ${heightPx}px (${pxToDp(context, heightPx)}dp)")
|
||||||
|
android.util.Log.d(TAG, " 📏 Old: ${oldHeight}px (${pxToDp(context, oldHeight)}dp)")
|
||||||
|
android.util.Log.d(TAG, " 🔄 Changed: $changed")
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
android.util.Log.d("KeyboardHeight", "💾 SAVED keyboard height: ${heightPx}px (${pxToDp(context, heightPx)}dp) [WAS: ${oldHeight}px]")
|
android.util.Log.d(TAG, " ✅ SAVED successfully!")
|
||||||
} else {
|
} else {
|
||||||
android.util.Log.v("KeyboardHeight", "💾 Same keyboard height: ${heightPx}px (no change)")
|
android.util.Log.d(TAG, " ⏭️ Same height, no change")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
android.util.Log.w("KeyboardHeight", "⚠️ Attempted to save invalid height: ${heightPx}px")
|
android.util.Log.w(TAG, " ⚠️ INVALID height: ${heightPx}px - NOT saved!")
|
||||||
}
|
}
|
||||||
|
android.util.Log.d(TAG, "═══════════════════════════════════════")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить длительность анимации клавиатуры
|
* Получить длительность анимации клавиатуры
|
||||||
* Telegram использует: AdjustPanLayoutHelper.keyboardDuration (250ms обычно)
|
* Telegram использует: AdjustPanLayoutHelper.keyboardDuration (250ms обычно)
|
||||||
*/
|
*/
|
||||||
fun getKeyboardAnimationDuration(): Long = 250L
|
fun getKeyboardAnimationDuration(): Long {
|
||||||
|
android.util.Log.d(TAG, "⏱️ getKeyboardAnimationDuration() = 250ms")
|
||||||
|
return 250L
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
private fun dpToPx(context: Context, dp: Int): Int {
|
private fun dpToPx(context: Context, dp: Int): Int {
|
||||||
@@ -91,7 +107,9 @@ fun rememberSavedKeyboardHeight(): Dp {
|
|||||||
val density = context.resources.displayMetrics.density
|
val density = context.resources.displayMetrics.density
|
||||||
val heightDp = (heightPx / density).dp
|
val heightDp = (heightPx / density).dp
|
||||||
|
|
||||||
android.util.Log.d("KeyboardHeight", "🎯 rememberSavedKeyboardHeight: ${heightDp} (${heightPx}px, density=${density})")
|
android.util.Log.d("KeyboardHeight", "🎯 rememberSavedKeyboardHeight()")
|
||||||
|
android.util.Log.d("KeyboardHeight", " 📏 Result: $heightDp (${heightPx}px)")
|
||||||
|
android.util.Log.d("KeyboardHeight", " 📱 Density: $density")
|
||||||
|
|
||||||
return heightDp
|
return heightDp
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user