feat: Implement automatic keyboard hiding on screen exit for improved user experience

This commit is contained in:
k1ngsterr1
2026-01-13 21:07:07 +05:00
parent f155c4d36d
commit 912412bd56
8 changed files with 467 additions and 344 deletions

View File

@@ -67,6 +67,8 @@ import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.HideKeyboardOnDispose
import com.rosetta.messenger.ui.components.rememberKeyboardController
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import android.view.inputmethod.InputMethodManager
@@ -216,11 +218,22 @@ fun ChatDetailScreen(
onUserProfileClick: () -> Unit = {},
viewModel: ChatViewModel = viewModel()
) {
// 🔥 Автоматическое скрытие клавиатуры при выходе с экрана
HideKeyboardOnDispose()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
val context = LocalContext.current
val view = LocalView.current
val keyboard = rememberKeyboardController()
val database = remember { com.rosetta.messenger.database.RosettaDatabase.getDatabase(context) }
// 🔔 Badge: количество непрочитанных сообщений из других чатов
val totalUnreadFromOthers by database.dialogDao()
.getTotalUnreadCountExcludingFlow(currentUserPublicKey, user.publicKey)
.collectAsState(initial = 0)
// Цвета как в React Native themes.ts
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -235,16 +248,14 @@ fun ChatDetailScreen(
val scope = rememberCoroutineScope()
val density = LocalDensity.current
// 🔥 Отслеживаем высоту клавиатуры для поднятия контента
val imeInsets = WindowInsets.ime
val imeHeight = with(density) { imeInsets.getBottom(density).toDp() }
val isKeyboardVisible = imeHeight > 0.dp
// 🔥 Emoji picker state (поднят из MessageInputBar для KeyboardAvoidingView)
var showEmojiPicker by remember { mutableStateOf(false) }
// Высота эмодзи панели - берём высоту клавиатуры если она открыта, иначе 280dp
val imeInsets = WindowInsets.ime
val imeHeight = with(density) { imeInsets.getBottom(density).toDp() }
val emojiPanelHeight = if (imeHeight > 50.dp) imeHeight else 280.dp
// 🔥 Reply/Forward state (нужен для расчёта listBottomPadding)
// 🔥 Reply/Forward state
val replyMessages by viewModel.replyMessages.collectAsState()
val hasReply = replyMessages.isNotEmpty()
@@ -261,17 +272,6 @@ fun ChatDetailScreen(
}
}
}
// 🔥 Дополнительная высота для 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 -> 70.dp + replyPanelHeight // Было 100.dp, теперь одинаково для всех состояний
}
// Telegram-style scroll tracking
var wasManualScroll by remember { mutableStateOf(false) }
@@ -525,13 +525,37 @@ fun ChatDetailScreen(
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Кнопка назад
IconButton(onClick = hideKeyboardAndBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back",
tint = headerIconColor
)
// 🔔 Кнопка назад с badge непрочитанных сообщений
Box {
IconButton(onClick = hideKeyboardAndBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back",
tint = headerIconColor
)
}
// Badge с количеством непрочитанных из других чатов
if (totalUnreadFromOthers > 0) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = (-4).dp, y = 6.dp)
.size(if (totalUnreadFromOthers > 9) 20.dp else 18.dp)
.clip(CircleShape)
.background(PrimaryBlue),
contentAlignment = Alignment.Center
) {
Text(
text = if (totalUnreadFromOthers > 99) "99+"
else if (totalUnreadFromOthers > 9) "$totalUnreadFromOthers"
else "$totalUnreadFromOthers",
color = Color.White,
fontSize = if (totalUnreadFromOthers > 9) 9.sp else 10.sp,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
}
// Аватар
@@ -806,22 +830,180 @@ fun ChatDetailScreen(
}
} // Закрытие AnimatedVisibility для normal header
},
containerColor = backgroundColor // Фон всего чата
containerColor = backgroundColor, // Фон всего чата
// 🔥 Bottom bar - инпут с imePadding автоматически поднимается над клавиатурой
bottomBar = {
// 🔥 FLOATING INPUT BAR - плавает поверх сообщений, поднимается с клавиатурой
// Скрываем когда в режиме выбора
AnimatedVisibility(
visible = !isSelectionMode,
enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }),
exit = fadeOut(tween(150)) + slideOutVertically(targetOffsetY = { it })
) {
Column(modifier = Modifier.imePadding()) {
// Input bar с встроенным reply preview (как в React Native)
MessageInputBar(
value = inputText,
onValueChange = {
viewModel.updateInputText(it)
// Отправляем индикатор печатания
if (it.isNotEmpty() && !isSavedMessages) {
viewModel.sendTypingIndicator()
}
},
onSend = {
android.util.Log.d("ChatDetailScreen", "🔥🔥🔥 onSend callback CALLED 🔥🔥🔥")
android.util.Log.d("ChatDetailScreen", "📝 inputText: '$inputText'")
// Скрываем кнопку scroll на время отправки
isSendingMessage = true
android.util.Log.d("ChatDetailScreen", "➡️ Calling viewModel.sendMessage()")
viewModel.sendMessage()
android.util.Log.d("ChatDetailScreen", "✅ viewModel.sendMessage() called")
// Скроллим к новому сообщению
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
)
}
}
// 🔥 SELECTION ACTION BAR - Reply/Forward (появляется при выборе сообщений)
// Плоский стиль как у инпута с border сверху
AnimatedVisibility(
visible = isSelectionMode,
enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }),
exit = fadeOut(tween(150)) + slideOutVertically(targetOffsetY = { it })
) {
Column(modifier = Modifier.imePadding()) {
// Плоский контейнер как у инпута
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
) {
// Border сверху
Box(
modifier = Modifier
.fillMaxWidth()
.height(0.5.dp)
.background(if (isDarkTheme) Color.White.copy(alpha = 0.15f) else Color.Black.copy(alpha = 0.1f))
)
// Кнопки Reply и Forward
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.navigationBarsPadding(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Reply button
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(PrimaryBlue.copy(alpha = 0.1f))
.clickable {
val selectedMsgs = messages
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
.sortedBy { it.timestamp }
viewModel.setReplyMessages(selectedMsgs)
selectedMessages = emptySet()
}
.padding(vertical = 14.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.SemiBold
)
}
}
// Forward button
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(PrimaryBlue.copy(alpha = 0.1f))
.clickable {
val selectedMsgs = messages
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
.sortedBy { it.timestamp }
viewModel.setForwardMessages(selectedMsgs)
selectedMessages = emptySet()
}
.padding(vertical = 14.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.SemiBold
)
}
}
}
}
}
}
}
) { paddingValues ->
// 🔥 Box с overlay - инпут плавает поверх сообщений
Box(
// 🔥 Column структура - список сжимается когда клавиатура открывается
Column(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { clip = false }
.padding(paddingValues)
.padding(top = paddingValues.calculateTopPadding())
.background(backgroundColor)
) {
// Список сообщений - динамический padding для клавиатуры/эмодзи
// 🔥 graphicsLayer(clip = false) - позволяет пузырькам выходить за границы padding
Box(modifier = Modifier
.fillMaxSize()
.graphicsLayer { clip = false }
.padding(bottom = listBottomPadding)
) {
// Список сообщений - занимает всё доступное место
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when {
// 🔥 СКЕЛЕТОН - показываем пока загружаются сообщения
isLoading -> {
@@ -832,84 +1014,79 @@ fun ChatDetailScreen(
}
// Пустое состояние (нет сообщений)
messages.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (isSavedMessages) {
Icon(
Icons.Default.Bookmark,
contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.5f),
modifier = Modifier.size(64.dp)
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (isSavedMessages) {
Icon(
Icons.Default.Bookmark,
contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.5f),
modifier = Modifier.size(64.dp)
)
} else {
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.speech))
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = LottieConstants.IterateForever
)
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(120.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text =
if (isSavedMessages)
"Save messages here for quick access"
else "No messages yet",
fontSize = 16.sp,
color = secondaryTextColor,
fontWeight = FontWeight.Medium
)
} else {
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.speech))
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = LottieConstants.IterateForever
)
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(120.dp)
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
if (isSavedMessages)
"Forward messages here or send notes to yourself"
else "Send a message to start the conversation",
fontSize = 14.sp,
color = secondaryTextColor.copy(alpha = 0.7f)
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text =
if (isSavedMessages)
"Save messages here for quick access"
else "No messages yet",
fontSize = 16.sp,
color = secondaryTextColor,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
if (isSavedMessages)
"Forward messages here or send notes to yourself"
else "Send a message to start the conversation",
fontSize = 14.sp,
color = secondaryTextColor.copy(alpha = 0.7f)
)
}
}
// Есть сообщения
else -> LazyColumn(
state = listState,
modifier =
Modifier.fillMaxSize()
.nestedScroll(
remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
// Отслеживаем ручную прокрутку
// пользователем
if (source ==
NestedScrollSource
.Drag
) {
wasManualScroll = true
}
return Offset.Zero
}
modifier = Modifier.fillMaxSize()
.nestedScroll(
remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
// Отслеживаем ручную прокрутку
// пользователем
if (source == NestedScrollSource.Drag) {
wasManualScroll = true
}
return Offset.Zero
}
),
// padding для контента списка - фиксированный для инпута
contentPadding =
PaddingValues(
start = 8.dp,
end = 8.dp,
top = 8.dp,
bottom = 16.dp // Небольшой отступ снизу
}
}
),
// padding для контента списка - минимальные отступы
contentPadding = PaddingValues(
start = 8.dp,
end = 8.dp,
top = 8.dp,
bottom = 8.dp
),
reverseLayout = true
) {
// Reversed layout: item 0 = самое новое сообщение (внизу экрана)
@@ -977,246 +1154,6 @@ fun ChatDetailScreen(
}
}
}
// TODO: Временно отключена кнопка скролла вниз
/*
// Telegram-style "Scroll to Bottom" кнопка - Liquid Glass стиль
// Не показываем при отправке сообщения (чтобы не мигала)
if (!isAtBottom && messages.isNotEmpty() && !isSendingMessage) {
Box(
modifier =
Modifier.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 80.dp)
.size(44.dp)
.shadow(
elevation = 8.dp,
shape = CircleShape,
clip = false,
ambientColor = Color.Black.copy(alpha = 0.3f),
spotColor = Color.Black.copy(alpha = 0.3f)
)
.clip(CircleShape)
.background(
brush =
Brush.verticalGradient(
colors =
if (isDarkTheme) {
listOf(
Color(0xFF2D2D2F)
.copy(alpha = 0.92f),
Color(0xFF1C1C1E)
.copy(alpha = 0.96f)
)
} else {
listOf(
Color(0xFFF2F2F7)
.copy(alpha = 0.94f),
Color(0xFFE5E5EA)
.copy(alpha = 0.97f)
)
}
)
)
.border(
width = 1.dp,
brush =
Brush.verticalGradient(
colors =
if (isDarkTheme) {
listOf(
Color.White.copy(alpha = 0.18f),
Color.White.copy(alpha = 0.06f)
)
} else {
listOf(
Color.White.copy(alpha = 0.9f),
Color.Black.copy(alpha = 0.05f)
)
}
),
shape = CircleShape
)
.clickable {
scope.launch {
wasManualScroll = false
listState.animateScrollToItem(0)
}
},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = "Scroll to bottom",
tint = if (isDarkTheme) Color.White.copy(alpha = 0.85f) else Color.Black.copy(alpha = 0.7f),
modifier = Modifier.size(24.dp)
)
}
}
*/
}
// 🔥 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()
) {
// Input bar с встроенным reply preview (как в React Native)
MessageInputBar(
value = inputText,
onValueChange = {
viewModel.updateInputText(it)
// Отправляем индикатор печатания
if (it.isNotEmpty() && !isSavedMessages) {
viewModel.sendTypingIndicator()
}
},
onSend = {
android.util.Log.d("ChatDetailScreen", "🔥🔥🔥 onSend callback CALLED 🔥🔥🔥")
android.util.Log.d("ChatDetailScreen", "📝 inputText: '$inputText'")
// Скрываем кнопку scroll на время отправки
isSendingMessage = true
android.util.Log.d("ChatDetailScreen", "➡️ Calling viewModel.sendMessage()")
viewModel.sendMessage()
android.util.Log.d("ChatDetailScreen", "✅ viewModel.sendMessage() called")
// Скроллим к новому сообщению
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
)
}
// 🔥 SELECTION ACTION BAR - Reply/Forward (появляется при выборе сообщений)
// Плоский стиль как у инпута с border сверху
AnimatedVisibility(
visible = isSelectionMode,
enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }),
exit = fadeOut(tween(150)) + slideOutVertically(targetOffsetY = { it }),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.ime.only(WindowInsetsSides.Bottom))
) {
// Плоский контейнер как у инпута
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
) {
// Border сверху
Box(
modifier = Modifier
.fillMaxWidth()
.height(0.5.dp)
.background(if (isDarkTheme) Color.White.copy(alpha = 0.15f) else Color.Black.copy(alpha = 0.1f))
)
// Кнопки Reply и Forward
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = if (WindowInsets.ime.getBottom(LocalDensity.current) == 0) 16.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Reply button
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(PrimaryBlue.copy(alpha = 0.1f))
.clickable {
val selectedMsgs = messages
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
.sortedBy { it.timestamp }
viewModel.setReplyMessages(selectedMsgs)
selectedMessages = emptySet()
}
.padding(vertical = 14.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.SemiBold
)
}
}
// Forward button
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(PrimaryBlue.copy(alpha = 0.1f))
.clickable {
val selectedMsgs = messages
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
.sortedBy { it.timestamp }
viewModel.setForwardMessages(selectedMsgs)
selectedMessages = emptySet()
}
.padding(vertical = 14.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.SemiBold
)
}
}
}
}
}
}
}

View File

@@ -25,6 +25,7 @@ import androidx.compose.ui.unit.sp
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.components.HideKeyboardOnDispose
// Primary Blue color
private val PrimaryBlue = Color(0xFF54A9EB)
@@ -43,6 +44,9 @@ fun SearchScreen(
onBackClick: () -> Unit,
onUserSelect: (SearchUser) -> Unit
) {
// 🔥 Автоматическое скрытие клавиатуры при выходе с экрана
HideKeyboardOnDispose()
// Цвета ТОЧНО как в ChatsListScreen
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color(0xFF1a1a1a)