From 912412bd56a5aa326a22f88b8e81bbc0ce6a472b Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 13 Jan 2026 21:07:07 +0500 Subject: [PATCH] feat: Implement automatic keyboard hiding on screen exit for improved user experience --- .../messenger/database/MessageEntities.kt | 16 + .../ui/auth/ConfirmSeedPhraseScreen.kt | 4 + .../messenger/ui/auth/SelectAccountScreen.kt | 4 + .../messenger/ui/auth/SetPasswordScreen.kt | 4 + .../rosetta/messenger/ui/auth/UnlockScreen.kt | 4 + .../messenger/ui/chats/ChatDetailScreen.kt | 625 ++++++++---------- .../messenger/ui/chats/SearchScreen.kt | 4 + .../ui/components/KeyboardController.kt | 150 +++++ 8 files changed, 467 insertions(+), 344 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/KeyboardController.kt diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index 9e4dab2..6cf6f73 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -333,4 +333,20 @@ interface DialogDao { username: String, verified: Int ) + + /** + * Получить общее количество непрочитанных сообщений, исключая указанный диалог + * Используется для отображения badge на кнопке "назад" в экране чата + */ + @Query(""" + SELECT COALESCE(SUM(unread_count), 0) FROM dialogs + WHERE account = :account AND opponent_key != :excludeOpponentKey + """) + fun getTotalUnreadCountExcludingFlow(account: String, excludeOpponentKey: String): Flow + + /** + * Получить общее количество непрочитанных сообщений + */ + @Query("SELECT COALESCE(SUM(unread_count), 0) FROM dialogs WHERE account = :account") + fun getTotalUnreadCountFlow(account: String): Flow } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt index cbd9cdf..f348a82 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import com.rosetta.messenger.ui.components.HideKeyboardOnDispose // Beautiful solid colors that fit the theme private val wordColors = listOf( @@ -50,6 +51,9 @@ fun ConfirmSeedPhraseScreen( onBack: () -> Unit, onConfirmed: () -> Unit ) { + // 🔥 Автоматическое скрытие клавиатуры при выходе с экрана + HideKeyboardOnDispose() + val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt index 0a0cccd..3f4dd3d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import com.rosetta.messenger.ui.components.HideKeyboardOnDispose data class AccountInfo( val id: String, @@ -63,6 +64,9 @@ fun SelectAccountScreen( onImportSeed: () -> Unit, onDismissModal: () -> Unit ) { + // 🔥 Автоматическое скрытие клавиатуры при выходе с экрана + HideKeyboardOnDispose() + val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index 352d285..fe9aa53 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -31,6 +31,7 @@ import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import com.rosetta.messenger.ui.components.HideKeyboardOnDispose import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -41,6 +42,9 @@ fun SetPasswordScreen( onBack: () -> Unit, onAccountCreated: (DecryptedAccount) -> Unit ) { + // 🔥 Автоматическое скрытие клавиатуры при выходе с экрана + HideKeyboardOnDispose() + val themeAnimSpec = tween(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec) val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index 3ccee7c..75e8a16 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -42,6 +42,7 @@ import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.chats.getAvatarColor import com.rosetta.messenger.ui.chats.getAvatarText +import com.rosetta.messenger.ui.components.HideKeyboardOnDispose import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -60,6 +61,9 @@ fun UnlockScreen( onUnlocked: (DecryptedAccount) -> Unit, onSwitchAccount: () -> Unit = {} ) { + // 🔥 Автоматическое скрытие клавиатуры при выходе с экрана + HideKeyboardOnDispose() + val themeAnimSpec = tween(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec) val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 293cabb..7901e64 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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 - ) - } - } - } - } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 2da2ca8..f1a2426 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/KeyboardController.kt b/app/src/main/java/com/rosetta/messenger/ui/components/KeyboardController.kt new file mode 100644 index 0000000..e9eb7ec --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/KeyboardController.kt @@ -0,0 +1,150 @@ +package com.rosetta.messenger.ui.components + +import android.content.Context +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +/** + * 🎹 Контроллер клавиатуры для Rosetta Messenger + * + * Использует современный WindowInsetsController (API 30+) для мгновенного + * скрытия клавиатуры. Решает проблему "залипания" клавиатуры при навигации. + * + * Использование: + * ```kotlin + * @Composable + * fun MyScreen() { + * // Автоматически скрывает клавиатуру при выходе с экрана + * HideKeyboardOnDispose() + * + * // Или получите контроллер для ручного управления + * val keyboard = rememberKeyboardController() + * Button(onClick = { keyboard.hide() }) { Text("Hide") } + * } + * ``` + */ +class KeyboardManager( + private val view: View, + private val context: Context +) { + /** + * Скрыть клавиатуру мгновенно (WindowInsetsController - API 30+) + */ + fun hide() { + // WindowInsetsController - самый быстрый способ (API 30+) + ViewCompat.getWindowInsetsController(view)?.hide(WindowInsetsCompat.Type.ime()) + } + + /** + * Показать клавиатуру + */ + fun show() { + ViewCompat.getWindowInsetsController(view)?.show(WindowInsetsCompat.Type.ime()) + } + + /** + * Скрыть клавиатуру с fallback для старых устройств + */ + fun hideWithFallback() { + // Сначала пробуем WindowInsetsController + ViewCompat.getWindowInsetsController(view)?.hide(WindowInsetsCompat.Type.ime()) + + // Fallback через InputMethodManager + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(view.windowToken, 0) + } + + /** + * Проверить, видна ли клавиатура + */ + fun isVisible(): Boolean { + val insets = ViewCompat.getRootWindowInsets(view) + return insets?.isVisible(WindowInsetsCompat.Type.ime()) == true + } +} + +/** + * Composable для получения KeyboardManager + */ +@Composable +fun rememberKeyboardController(): KeyboardManager { + val view = LocalView.current + val context = LocalContext.current + return remember(view, context) { KeyboardManager(view, context) } +} + +/** + * 🔥 Автоматически скрывает клавиатуру при выходе с экрана (onDispose) + * + * Добавьте в начало любого Composable экрана с клавиатурой: + * ```kotlin + * @Composable + * fun ChatScreen() { + * HideKeyboardOnDispose() + * // ... остальной UI + * } + * ``` + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun HideKeyboardOnDispose() { + val keyboard = rememberKeyboardController() + val keyboardController = LocalSoftwareKeyboardController.current + + DisposableEffect(Unit) { + onDispose { + // WindowInsetsController - мгновенное скрытие + keyboard.hide() + // Fallback для Compose + keyboardController?.hide() + } + } +} + +/** + * 🔥 Расширенная версия с очисткой фокуса + * + * Использовать когда нужно также сбросить фокус с TextField + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun HideKeyboardAndClearFocusOnDispose() { + val keyboard = rememberKeyboardController() + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + DisposableEffect(Unit) { + onDispose { + // Сбрасываем фокус + focusManager.clearFocus(force = true) + // WindowInsetsController - мгновенное скрытие + keyboard.hide() + // Fallback для Compose + keyboardController?.hide() + } + } +} + +/** + * Extension function для View - скрыть клавиатуру мгновенно + */ +fun View.hideKeyboardNow() { + ViewCompat.getWindowInsetsController(this)?.hide(WindowInsetsCompat.Type.ime()) +} + +/** + * Extension function для View - показать клавиатуру + */ +fun View.showKeyboardNow() { + ViewCompat.getWindowInsetsController(this)?.show(WindowInsetsCompat.Type.ime()) +}