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,
|
username: String,
|
||||||
verified: Int
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import com.rosetta.messenger.ui.components.HideKeyboardOnDispose
|
||||||
|
|
||||||
// Beautiful solid colors that fit the theme
|
// Beautiful solid colors that fit the theme
|
||||||
private val wordColors = listOf(
|
private val wordColors = listOf(
|
||||||
@@ -50,6 +51,9 @@ fun ConfirmSeedPhraseScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onConfirmed: () -> Unit
|
onConfirmed: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
// 🔥 Автоматическое скрытие клавиатуры при выходе с экрана
|
||||||
|
HideKeyboardOnDispose()
|
||||||
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import com.rosetta.messenger.ui.components.HideKeyboardOnDispose
|
||||||
|
|
||||||
data class AccountInfo(
|
data class AccountInfo(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -63,6 +64,9 @@ fun SelectAccountScreen(
|
|||||||
onImportSeed: () -> Unit,
|
onImportSeed: () -> Unit,
|
||||||
onDismissModal: () -> Unit
|
onDismissModal: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
// 🔥 Автоматическое скрытие клавиатуры при выходе с экрана
|
||||||
|
HideKeyboardOnDispose()
|
||||||
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
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.data.EncryptedAccount
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import com.rosetta.messenger.ui.components.HideKeyboardOnDispose
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -41,6 +42,9 @@ fun SetPasswordScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAccountCreated: (DecryptedAccount) -> Unit
|
onAccountCreated: (DecryptedAccount) -> Unit
|
||||||
) {
|
) {
|
||||||
|
// 🔥 Автоматическое скрытие клавиатуры при выходе с экрана
|
||||||
|
HideKeyboardOnDispose()
|
||||||
|
|
||||||
val themeAnimSpec = tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
|
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 backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec)
|
||||||
val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, 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.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.ui.chats.getAvatarColor
|
import com.rosetta.messenger.ui.chats.getAvatarColor
|
||||||
import com.rosetta.messenger.ui.chats.getAvatarText
|
import com.rosetta.messenger.ui.chats.getAvatarText
|
||||||
|
import com.rosetta.messenger.ui.components.HideKeyboardOnDispose
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ fun UnlockScreen(
|
|||||||
onUnlocked: (DecryptedAccount) -> Unit,
|
onUnlocked: (DecryptedAccount) -> Unit,
|
||||||
onSwitchAccount: () -> Unit = {}
|
onSwitchAccount: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
// 🔥 Автоматическое скрытие клавиатуры при выходе с экрана
|
||||||
|
HideKeyboardOnDispose()
|
||||||
|
|
||||||
val themeAnimSpec = tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
|
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 backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec)
|
||||||
val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, 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.AppleEmojiPickerPanel
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
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.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
@@ -216,11 +218,22 @@ fun ChatDetailScreen(
|
|||||||
onUserProfileClick: () -> Unit = {},
|
onUserProfileClick: () -> Unit = {},
|
||||||
viewModel: ChatViewModel = viewModel()
|
viewModel: ChatViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
|
// 🔥 Автоматическое скрытие клавиатуры при выходе с экрана
|
||||||
|
HideKeyboardOnDispose()
|
||||||
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val view = LocalView.current
|
||||||
|
val keyboard = rememberKeyboardController()
|
||||||
val database = remember { com.rosetta.messenger.database.RosettaDatabase.getDatabase(context) }
|
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
|
// Цвета как в React Native themes.ts
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
@@ -235,16 +248,14 @@ fun ChatDetailScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val density = LocalDensity.current
|
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)
|
// 🔥 Emoji picker state (поднят из MessageInputBar для KeyboardAvoidingView)
|
||||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
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
|
val emojiPanelHeight = if (imeHeight > 50.dp) imeHeight else 280.dp
|
||||||
|
|
||||||
// 🔥 Reply/Forward state (нужен для расчёта listBottomPadding)
|
// 🔥 Reply/Forward state
|
||||||
val replyMessages by viewModel.replyMessages.collectAsState()
|
val replyMessages by viewModel.replyMessages.collectAsState()
|
||||||
val hasReply = replyMessages.isNotEmpty()
|
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
|
// Telegram-style scroll tracking
|
||||||
var wasManualScroll by remember { mutableStateOf(false) }
|
var wasManualScroll by remember { mutableStateOf(false) }
|
||||||
@@ -525,13 +525,37 @@ fun ChatDetailScreen(
|
|||||||
.padding(horizontal = 4.dp),
|
.padding(horizontal = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Кнопка назад
|
// 🔔 Кнопка назад с badge непрочитанных сообщений
|
||||||
IconButton(onClick = hideKeyboardAndBack) {
|
Box {
|
||||||
Icon(
|
IconButton(onClick = hideKeyboardAndBack) {
|
||||||
Icons.Default.ArrowBack,
|
Icon(
|
||||||
contentDescription = "Back",
|
Icons.Default.ArrowBack,
|
||||||
tint = headerIconColor
|
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
|
} // Закрытие 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 ->
|
) { paddingValues ->
|
||||||
// 🔥 Box с overlay - инпут плавает поверх сообщений
|
// 🔥 Column структура - список сжимается когда клавиатура открывается
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.graphicsLayer { clip = false }
|
.padding(top = paddingValues.calculateTopPadding())
|
||||||
.padding(paddingValues)
|
.background(backgroundColor)
|
||||||
) {
|
) {
|
||||||
// Список сообщений - динамический padding для клавиатуры/эмодзи
|
// Список сообщений - занимает всё доступное место
|
||||||
// 🔥 graphicsLayer(clip = false) - позволяет пузырькам выходить за границы padding
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
Box(modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer { clip = false }
|
|
||||||
.padding(bottom = listBottomPadding)
|
|
||||||
) {
|
|
||||||
when {
|
when {
|
||||||
// 🔥 СКЕЛЕТОН - показываем пока загружаются сообщения
|
// 🔥 СКЕЛЕТОН - показываем пока загружаются сообщения
|
||||||
isLoading -> {
|
isLoading -> {
|
||||||
@@ -832,84 +1014,79 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
// Пустое состояние (нет сообщений)
|
// Пустое состояние (нет сообщений)
|
||||||
messages.isEmpty() -> {
|
messages.isEmpty() -> {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
if (isSavedMessages) {
|
if (isSavedMessages) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Bookmark,
|
Icons.Default.Bookmark,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = secondaryTextColor.copy(alpha = 0.5f),
|
tint = secondaryTextColor.copy(alpha = 0.5f),
|
||||||
modifier = Modifier.size(64.dp)
|
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 {
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.speech))
|
Text(
|
||||||
val progress by animateLottieCompositionAsState(
|
text =
|
||||||
composition = composition,
|
if (isSavedMessages)
|
||||||
iterations = LottieConstants.IterateForever
|
"Forward messages here or send notes to yourself"
|
||||||
)
|
else "Send a message to start the conversation",
|
||||||
LottieAnimation(
|
fontSize = 14.sp,
|
||||||
composition = composition,
|
color = secondaryTextColor.copy(alpha = 0.7f)
|
||||||
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
|
|
||||||
)
|
|
||||||
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(
|
else -> LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier =
|
modifier = Modifier.fillMaxSize()
|
||||||
Modifier.fillMaxSize()
|
.nestedScroll(
|
||||||
.nestedScroll(
|
remember {
|
||||||
remember {
|
object : NestedScrollConnection {
|
||||||
object : NestedScrollConnection {
|
override fun onPreScroll(
|
||||||
override fun onPreScroll(
|
available: Offset,
|
||||||
available: Offset,
|
source: NestedScrollSource
|
||||||
source: NestedScrollSource
|
): Offset {
|
||||||
): Offset {
|
// Отслеживаем ручную прокрутку
|
||||||
// Отслеживаем ручную прокрутку
|
// пользователем
|
||||||
// пользователем
|
if (source == NestedScrollSource.Drag) {
|
||||||
if (source ==
|
wasManualScroll = true
|
||||||
NestedScrollSource
|
|
||||||
.Drag
|
|
||||||
) {
|
|
||||||
wasManualScroll = true
|
|
||||||
}
|
|
||||||
return Offset.Zero
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
reverseLayout = true
|
||||||
) {
|
) {
|
||||||
// Reversed layout: item 0 = самое новое сообщение (внизу экрана)
|
// 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.data.RecentSearchesManager
|
||||||
import com.rosetta.messenger.network.ProtocolState
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
import com.rosetta.messenger.ui.components.HideKeyboardOnDispose
|
||||||
|
|
||||||
// Primary Blue color
|
// Primary Blue color
|
||||||
private val PrimaryBlue = Color(0xFF54A9EB)
|
private val PrimaryBlue = Color(0xFF54A9EB)
|
||||||
@@ -43,6 +44,9 @@ fun SearchScreen(
|
|||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onUserSelect: (SearchUser) -> Unit
|
onUserSelect: (SearchUser) -> Unit
|
||||||
) {
|
) {
|
||||||
|
// 🔥 Автоматическое скрытие клавиатуры при выходе с экрана
|
||||||
|
HideKeyboardOnDispose()
|
||||||
|
|
||||||
// Цвета ТОЧНО как в ChatsListScreen
|
// Цвета ТОЧНО как в ChatsListScreen
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color(0xFF1a1a1a)
|
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