feat: Implement automatic keyboard hiding on screen exit for improved user experience
This commit is contained in:
@@ -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<Int>
|
||||
|
||||
/**
|
||||
* Получить общее количество непрочитанных сообщений
|
||||
*/
|
||||
@Query("SELECT COALESCE(SUM(unread_count), 0) FROM dialogs WHERE account = :account")
|
||||
fun getTotalUnreadCountFlow(account: String): Flow<Int>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Color>(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)
|
||||
|
||||
@@ -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<Color>(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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user