From 6b1c84a7bc283f22efa43e0f25db367b8383aad9 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 20 Jan 2026 01:54:29 +0500 Subject: [PATCH] Refactor ChatsListScreen: Remove FCM logs dialog and related functionality - Eliminated the FCM logs dialog and its associated state management. - Removed FCM logs display logic from the UI. - Updated drawer menu by removing FCM Token Logs option and other unused items. - Changed icon for Saved Messages from Outlined to Default. --- .../messenger/ui/chats/ChatDetailScreen.kt | 6466 +++++++++-------- .../messenger/ui/chats/ChatsListScreen.kt | 160 +- 2 files changed, 3627 insertions(+), 2999 deletions(-) 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 b169212..8a51372 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 @@ -50,6 +50,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight @@ -61,7 +62,6 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator @@ -90,47 +90,65 @@ val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f) /** Telegram Send Icon (горизонтальный самолетик) - кастомная SVG иконка */ private val TelegramSendIcon: ImageVector - get() = - ImageVector.Builder( - name = "TelegramSendHorizontal", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f - ) - .apply { - path( - fill = null, - stroke = SolidColor(Color.White), - strokeLineWidth = 2f, - strokeLineCap = StrokeCap.Round, - strokeLineJoin = StrokeJoin.Round - ) { - // Path 1: M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 - // 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z - moveTo(3.714f, 3.048f) - arcToRelative(0.498f, 0.498f, 0f, false, false, -0.683f, 0.627f) - lineToRelative(2.843f, 7.627f) - arcToRelative(2f, 2f, 0f, false, true, 0f, 1.396f) - lineToRelative(-2.842f, 7.627f) - arcToRelative(0.498f, 0.498f, 0f, false, false, 0.682f, 0.627f) - lineToRelative(18f, -8.5f) - arcToRelative(0.5f, 0.5f, 0f, false, false, 0f, -0.904f) - close() + get() = + ImageVector.Builder( + name = "TelegramSendHorizontal", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ) + .apply { + path( + fill = null, + stroke = SolidColor(Color.White), + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + // Path 1: M3.714 3.048a.498.498 0 0 0-.683.627l2.843 + // 7.627a2 2 0 0 1 0 + // 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 + // 0 0 0-.904z + moveTo(3.714f, 3.048f) + arcToRelative( + 0.498f, + 0.498f, + 0f, + false, + false, + -0.683f, + 0.627f + ) + lineToRelative(2.843f, 7.627f) + arcToRelative(2f, 2f, 0f, false, true, 0f, 1.396f) + lineToRelative(-2.842f, 7.627f) + arcToRelative( + 0.498f, + 0.498f, + 0f, + false, + false, + 0.682f, + 0.627f + ) + lineToRelative(18f, -8.5f) + arcToRelative(0.5f, 0.5f, 0f, false, false, 0f, -0.904f) + close() + } + path( + fill = null, + stroke = SolidColor(Color.White), + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + // Path 2: M6 12h16 + moveTo(6f, 12f) + horizontalLineToRelative(16f) + } } - path( - fill = null, - stroke = SolidColor(Color.White), - strokeLineWidth = 2f, - strokeLineCap = StrokeCap.Round, - strokeLineJoin = StrokeJoin.Round - ) { - // Path 2: M6 12h16 - moveTo(6f, 12f) - horizontalLineToRelative(16f) - } - } - .build() + .build() /** Данные цитируемого сообщения */ data class ReplyData( @@ -152,11 +170,11 @@ data class ChatMessage( ) enum class MessageStatus { - SENDING, - SENT, - DELIVERED, - READ, - ERROR // 🔥 Ошибка отправки (таймаут или реальная ошибка) + SENDING, + SENT, + DELIVERED, + READ, + ERROR // 🔥 Ошибка отправки (таймаут или реальная ошибка) } // 🔥 Константа таймаута доставки (как в архиве - 80 секунд) @@ -164,29 +182,30 @@ private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L /** Проверка: сообщение ещё может быть доставлено (не истёк таймаут) */ private fun isMessageDeliveredByTime(timestamp: Long, attachmentsCount: Int = 0): Boolean { - val maxTime = - if (attachmentsCount > 0) { - MESSAGE_MAX_TIME_TO_DELIVERED_MS * attachmentsCount - } else { - MESSAGE_MAX_TIME_TO_DELIVERED_MS - } - return System.currentTimeMillis() - timestamp < maxTime + val maxTime = + if (attachmentsCount > 0) { + MESSAGE_MAX_TIME_TO_DELIVERED_MS * attachmentsCount + } else { + MESSAGE_MAX_TIME_TO_DELIVERED_MS + } + return System.currentTimeMillis() - timestamp < maxTime } /** Получить текст даты (today, yesterday или полная дата) */ private fun getDateText(timestamp: Long): String { - val messageDate = Calendar.getInstance().apply { timeInMillis = timestamp } - val today = Calendar.getInstance() - val yesterday = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) } + val messageDate = Calendar.getInstance().apply { timeInMillis = timestamp } + val today = Calendar.getInstance() + val yesterday = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) } - return when { - messageDate.get(Calendar.YEAR) == today.get(Calendar.YEAR) && - messageDate.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) -> "today" - messageDate.get(Calendar.YEAR) == yesterday.get(Calendar.YEAR) && - messageDate.get(Calendar.DAY_OF_YEAR) == yesterday.get(Calendar.DAY_OF_YEAR) -> - "yesterday" - else -> SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH).format(Date(timestamp)) - } + return when { + messageDate.get(Calendar.YEAR) == today.get(Calendar.YEAR) && + messageDate.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) -> + "today" + messageDate.get(Calendar.YEAR) == yesterday.get(Calendar.YEAR) && + messageDate.get(Calendar.DAY_OF_YEAR) == + yesterday.get(Calendar.DAY_OF_YEAR) -> "yesterday" + else -> SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH).format(Date(timestamp)) + } } // Extension для конвертации @@ -198,10 +217,10 @@ private fun Message.toChatMessage() = timestamp = Date(timestamp), status = when (deliveryStatus) { - DeliveryStatus.WAITING -> MessageStatus.SENDING - DeliveryStatus.DELIVERED -> - if (isRead) MessageStatus.READ else MessageStatus.DELIVERED - DeliveryStatus.ERROR -> MessageStatus.SENT + DeliveryStatus.WAITING -> MessageStatus.SENDING + DeliveryStatus.DELIVERED -> + if (isRead) MessageStatus.READ else MessageStatus.DELIVERED + DeliveryStatus.ERROR -> MessageStatus.SENT } ) @@ -222,1530 +241,2077 @@ fun ChatDetailScreen( onNavigateToChat: (String) -> Unit = {}, // 📨 Callback для навигации в другой чат (Forward) viewModel: ChatViewModel = viewModel() ) { - // 🔥 ОПТИМИЗАЦИЯ: Убрано логирование из композиции чтобы не засорять logcat + // 🔥 ОПТИМИЗАЦИЯ: Убрано логирование из композиции чтобы не засорять logcat - 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 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 = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) } - val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } - val secondaryTextColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } - val inputBackgroundColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF2F3F5) } - // Цвет иконок в хедере - синий как в React Native - val headerIconColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else PrimaryBlue } - - // 🔥 ОПТИМИЗАЦИЯ: Отложенный рендеринг тяжелого контента - var isContentReady by remember { mutableStateOf(false) } - - // Запускаем отложенный рендеринг после завершения анимации перехода - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(30) // Минимальная задержка для анимации - isContentReady = true - } - - // 🚀 Убираем дополнительную анимацию - используем только анимацию навигации из MainActivity - - val listState = rememberLazyListState() - val scope = rememberCoroutineScope() - val density = LocalDensity.current - - // 🔥 State для подсветки сообщения при клике на reply - var highlightedMessageId by remember { mutableStateOf(null) } - - // 🔥 Emoji picker state (поднят из MessageInputBar для KeyboardAvoidingView) - var showEmojiPicker by remember { mutableStateOf(false) } - - // 🎯 Координатор плавных переходов клавиатуры (Telegram-style) - val coordinator = rememberKeyboardTransitionCoordinator() - - // 🔥 ОПТИМИЗАЦИЯ: НЕ читаем imeHeight напрямую в композиции! - // Используем snapshotFlow чтобы избежать рекомпозиции на каждый пиксель анимации - val imeInsets = WindowInsets.ime - - // 🔥 Синхронизируем coordinator с IME высотой через snapshotFlow (БЕЗ рекомпозиции!) - LaunchedEffect(Unit) { - snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { - currentImeHeight -> - coordinator.updateKeyboardHeight(currentImeHeight) - if (currentImeHeight > 100.dp) { - coordinator.syncHeights() - } + 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 database = remember { + com.rosetta.messenger.database.RosettaDatabase.getDatabase(context) } - } - // 🔥 Инициализируем высоту emoji панели из сохранённой высоты клавиатуры - LaunchedEffect(Unit) { - val savedHeightPx = - com.rosetta.messenger.ui.components.KeyboardHeightProvider.getSavedKeyboardHeight( - context + // 🔔 Badge: количество непрочитанных сообщений из других чатов + val totalUnreadFromOthers by + database.dialogDao() + .getTotalUnreadCountExcludingFlow(currentUserPublicKey, user.publicKey) + .collectAsState(initial = 0) + + // Цвета как в React Native themes.ts - 🔥 КЭШИРУЕМ для производительности + val backgroundColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) } + val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } + val secondaryTextColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } + val inputBackgroundColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF2F3F5) } + // Цвет иконок в хедере - синий как в React Native + val headerIconColor = + remember(isDarkTheme) { if (isDarkTheme) Color.White else PrimaryBlue } + + // 🔥 ОПТИМИЗАЦИЯ: Отложенный рендеринг тяжелого контента + var isContentReady by remember { mutableStateOf(false) } + + // Запускаем отложенный рендеринг после завершения анимации перехода + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(30) // Минимальная задержка для анимации + isContentReady = true + } + + // 🚀 Убираем дополнительную анимацию - используем только анимацию навигации из MainActivity + + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + val density = LocalDensity.current + + // 🔥 State для подсветки сообщения при клике на reply + var highlightedMessageId by remember { mutableStateOf(null) } + + // 🔥 Emoji picker state (поднят из MessageInputBar для KeyboardAvoidingView) + var showEmojiPicker by remember { mutableStateOf(false) } + + // 🎯 Координатор плавных переходов клавиатуры (Telegram-style) + val coordinator = rememberKeyboardTransitionCoordinator() + + // 🔥 ОПТИМИЗАЦИЯ: НЕ читаем imeHeight напрямую в композиции! + // Используем snapshotFlow чтобы избежать рекомпозиции на каждый пиксель анимации + val imeInsets = WindowInsets.ime + + // 🔥 Синхронизируем coordinator с IME высотой через snapshotFlow (БЕЗ рекомпозиции!) + LaunchedEffect(Unit) { + snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { + currentImeHeight -> + coordinator.updateKeyboardHeight(currentImeHeight) + if (currentImeHeight > 100.dp) { + coordinator.syncHeights() + } + } + } + + // 🔥 Инициализируем высоту emoji панели из сохранённой высоты клавиатуры + LaunchedEffect(Unit) { + val savedHeightPx = + com.rosetta.messenger.ui.components.KeyboardHeightProvider + .getSavedKeyboardHeight(context) + if (savedHeightPx > 0) { + val savedHeightDp = with(density) { savedHeightPx.toDp() } + coordinator.initializeEmojiHeight(savedHeightDp) + } else { + coordinator.initializeEmojiHeight(280.dp) // fallback + } + } + + // 🔥 Reply/Forward state + val replyMessages by viewModel.replyMessages.collectAsState() + val hasReply = replyMessages.isNotEmpty() + + // 🔥 Snapshot последнего непустого состояния для отображения во время анимации закрытия + // Используем rememberSaveable с mutableStateOf чтобы сохранять данные пока панель + // закрывается + var displayReplyMessages by remember { mutableStateOf(replyMessages) } + + // Обновляем snapshot только когда появляются новые данные (не пустые) + LaunchedEffect(replyMessages) { + if (replyMessages.isNotEmpty()) { + displayReplyMessages = replyMessages + } + } + + // 🔥 FocusRequester для автофокуса на инпут при reply + val inputFocusRequester = remember { FocusRequester() } + + // 🔥 Автофокус на инпут при появлении reply панели + LaunchedEffect(hasReply) { + if (hasReply) { + try { + inputFocusRequester.requestFocus() + } catch (e: Exception) { + // Игнорируем если фокус не удался + } + } + } + + // Telegram-style scroll tracking + var wasManualScroll by remember { mutableStateOf(false) } + // Кнопка появляется после 3+ сообщений от начала (позже) + val isAtBottom by remember { + derivedStateOf { + listState.firstVisibleItemIndex < 3 && + listState.firstVisibleItemScrollOffset < 100 + } + } + // Флаг для скрытия кнопки scroll при отправке (чтобы не мигала) + var isSendingMessage by remember { mutableStateOf(false) } + + // 🔥 MESSAGE SELECTION STATE - для Reply/Forward + var selectedMessages by remember { mutableStateOf>(emptySet()) } + val isSelectionMode = selectedMessages.isNotEmpty() + + // Логирование изменений selection mode + LaunchedEffect(isSelectionMode, selectedMessages.size) {} + + // 🔥 Backup: если клавиатура ещё открыта когда selection mode активировался + // (клавиатура уже должна быть закрыта в onLongClick, это только backup) + LaunchedEffect(isSelectionMode) { + if (isSelectionMode) { + // Backup закрытие клавиатуры (основное в onLongClick) + keyboardController?.hide() + } + } + + // 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager + val hideKeyboardAndBack: () -> Unit = { + // Используем нативный InputMethodManager для МГНОВЕННОГО закрытия + val imm = + context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus() + onBack() + } + + // Определяем это Saved Messages или обычный чат + val isSavedMessages = user.publicKey == currentUserPublicKey + val chatTitle = + if (isSavedMessages) "Saved Messages" + else user.title.ifEmpty { user.publicKey.take(10) } + + // 📨 Forward: показывать ли выбор чата + var showForwardPicker by remember { mutableStateOf(false) } + + // 📨 Forward: список диалогов для выбора (загружаем из базы) + val chatsListViewModel: ChatsListViewModel = viewModel() + val dialogsList by chatsListViewModel.dialogs.collectAsState() + + // 📨 Forward: инициализируем ChatsListViewModel для получения списка диалогов + LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) { + if (currentUserPublicKey.isNotEmpty() && currentUserPrivateKey.isNotEmpty()) { + chatsListViewModel.setAccount(currentUserPublicKey, currentUserPrivateKey) + } + } + + // Состояние выпадающего меню + var showMenu by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } + var showBlockConfirm by remember { mutableStateOf(false) } + var showUnblockConfirm by remember { mutableStateOf(false) } + + // Проверяем, заблокирован ли пользователь (отложенно, не блокирует UI) + var isBlocked by remember { mutableStateOf(false) } + LaunchedEffect(user.publicKey, currentUserPublicKey) { + // 🔥 ОПТИМИЗАЦИЯ: Отложенная проверка - не блокирует анимацию + kotlinx.coroutines.delay(100) // Даём анимации завершиться + isBlocked = + database.blacklistDao().isUserBlocked(user.publicKey, currentUserPublicKey) + } + + // Подключаем к ViewModel + val messages by viewModel.messages.collectAsState() + val inputText by viewModel.inputText.collectAsState() + val isTyping by viewModel.opponentTyping.collectAsState() + val isOnline by viewModel.opponentOnline.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона + + // 🔥 Reply/Forward state (replyMessages и hasReply определены выше для listBottomPadding) + val isForwardMode by viewModel.isForwardMode.collectAsState() + + // 🔥 Добавляем информацию о датах к сообщениям + // В reversed layout (новые внизу) - показываем дату ПОСЛЕ сообщения (визуально сверху) + val messagesWithDates = + remember(messages) { + val result = + mutableListOf< + Pair>() // message, showDateHeader + var lastDateString = "" + + // Сортируем по времени (новые -> старые) для reversed layout + val sortedMessages = messages.sortedByDescending { it.timestamp.time } + + for (i in sortedMessages.indices) { + val message = sortedMessages[i] + val dateString = + SimpleDateFormat("yyyyMMdd", Locale.getDefault()) + .format(message.timestamp) + + // Показываем дату если это последнее сообщение за день + // (следующее сообщение - другой день или нет следующего) + val nextMessage = sortedMessages.getOrNull(i + 1) + val nextDateString = + nextMessage?.let { + SimpleDateFormat("yyyyMMdd", Locale.getDefault()) + .format(it.timestamp) + } + val showDate = + nextDateString == null || nextDateString != dateString + + result.add(message to showDate) + lastDateString = dateString + } + + result + } + + // 🔥 Функция для скролла к сообщению с подсветкой + val scrollToMessage: (String) -> Unit = { messageId -> + + // Логируем все ID сообщений для отладки + messagesWithDates.forEachIndexed { index, pair -> } + + scope.launch { + // 🔥 Сбрасываем текущую подсветку перед новым скроллом + highlightedMessageId = null + delay(50) // Небольшая задержка для сброса анимации + + // Находим индекс сообщения в списке + val messageIndex = + messagesWithDates.indexOfFirst { it.first.id == messageId } + if (messageIndex != -1) { + // Скроллим к сообщению + listState.animateScrollToItem(messageIndex) + + // Подсвечиваем на 2 секунды + highlightedMessageId = messageId + delay(2000) + highlightedMessageId = null + } else {} + } + } + + // Динамический subtitle: typing > online > offline + val chatSubtitle = + when { + isSavedMessages -> "Notes" + isTyping -> "" // Пустая строка, используем компонент TypingIndicator + isOnline -> "online" + else -> "offline" + } + + // 🔥 Обработка системной кнопки назад + BackHandler { hideKeyboardAndBack() } + + // 🔥 Lifecycle-aware отслеживание активности экрана + val lifecycleOwner = LocalLifecycleOwner.current + var isScreenActive by remember { mutableStateOf(true) } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + isScreenActive = true + viewModel.setDialogActive(true) + } + Lifecycle.Event.ON_PAUSE -> { + isScreenActive = false + viewModel.setDialogActive(false) + } + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + viewModel.closeDialog() + } + } + + // Инициализируем ViewModel с ключами и открываем диалог + LaunchedEffect(user.publicKey) { + viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey) + viewModel.openDialog(user.publicKey, user.title, user.username) + // Подписываемся на онлайн статус собеседника + if (!isSavedMessages) { + viewModel.subscribeToOnlineStatus() + } + // 🔥 Предзагружаем эмодзи в фоне + com.rosetta.messenger.ui.components.EmojiCache.preload(context) + } + + // Отмечаем сообщения как прочитанные только когда экран активен (RESUMED) + LaunchedEffect(messages, isScreenActive) { + if (messages.isNotEmpty() && isScreenActive) { + viewModel.markVisibleMessagesAsRead() + } + } + + // Telegram-style: Прокрутка при новых сообщениях + // Всегда скроллим к последнему при изменении количества сообщений + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + // Всегда скроллим вниз при новом сообщении + listState.animateScrollToItem(0) + wasManualScroll = false + } + } + + // Аватар - используем publicKey для консистентности цвета везде + val avatarColors = + getAvatarColor( + if (isSavedMessages) "SavedMessages" else user.publicKey, + isDarkTheme ) - if (savedHeightPx > 0) { - val savedHeightDp = with(density) { savedHeightPx.toDp() } - coordinator.initializeEmojiHeight(savedHeightDp) - } else { - coordinator.initializeEmojiHeight(280.dp) // fallback - } - } - // 🔥 Reply/Forward state - val replyMessages by viewModel.replyMessages.collectAsState() - val hasReply = replyMessages.isNotEmpty() + // � Edge swipe to go back (iOS/Telegram style) + var edgeSwipeOffset by remember { mutableStateOf(0f) } + val edgeSwipeThreshold = 100f // px threshold для активации + val edgeZoneWidth = 30f // px зона от левого края для начала свайпа + var isEdgeSwiping by remember { mutableStateOf(false) } - // 🔥 Snapshot последнего непустого состояния для отображения во время анимации закрытия - // Используем rememberSaveable с mutableStateOf чтобы сохранять данные пока панель закрывается - var displayReplyMessages by remember { mutableStateOf(replyMessages) } + // Анимация возврата + val animatedEdgeOffset by + animateFloatAsState( + targetValue = edgeSwipeOffset, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f), + label = "edgeSwipe" + ) - // Обновляем snapshot только когда появляются новые данные (не пустые) - LaunchedEffect(replyMessages) { - if (replyMessages.isNotEmpty()) { - displayReplyMessages = replyMessages - } - } - - // 🔥 FocusRequester для автофокуса на инпут при reply - val inputFocusRequester = remember { FocusRequester() } - - // 🔥 Автофокус на инпут при появлении reply панели - LaunchedEffect(hasReply) { - if (hasReply) { - try { - inputFocusRequester.requestFocus() - } catch (e: Exception) { - // Игнорируем если фокус не удался - } - } - } - - // Telegram-style scroll tracking - var wasManualScroll by remember { mutableStateOf(false) } - // Кнопка появляется после 3+ сообщений от начала (позже) - val isAtBottom by remember { - derivedStateOf { - listState.firstVisibleItemIndex < 3 && listState.firstVisibleItemScrollOffset < 100 - } - } - // Флаг для скрытия кнопки scroll при отправке (чтобы не мигала) - var isSendingMessage by remember { mutableStateOf(false) } - - // 🔥 MESSAGE SELECTION STATE - для Reply/Forward - var selectedMessages by remember { mutableStateOf>(emptySet()) } - val isSelectionMode = selectedMessages.isNotEmpty() - - // Логирование изменений selection mode - LaunchedEffect(isSelectionMode, selectedMessages.size) {} - - // 🔥 Backup: если клавиатура ещё открыта когда selection mode активировался - // (клавиатура уже должна быть закрыта в onLongClick, это только backup) - LaunchedEffect(isSelectionMode) { - if (isSelectionMode) { - // Backup закрытие клавиатуры (основное в onLongClick) - keyboardController?.hide() - } - } - - // 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager - val hideKeyboardAndBack: () -> Unit = { - // Используем нативный InputMethodManager для МГНОВЕННОГО закрытия - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() - onBack() - } - - // Определяем это Saved Messages или обычный чат - val isSavedMessages = user.publicKey == currentUserPublicKey - val chatTitle = - if (isSavedMessages) "Saved Messages" - else user.title.ifEmpty { user.publicKey.take(10) } - - // 📨 Forward: показывать ли выбор чата - var showForwardPicker by remember { mutableStateOf(false) } - - // 📨 Forward: список диалогов для выбора (загружаем из базы) - val chatsListViewModel: ChatsListViewModel = viewModel() - val dialogsList by chatsListViewModel.dialogs.collectAsState() - - // 📨 Forward: инициализируем ChatsListViewModel для получения списка диалогов - LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) { - if (currentUserPublicKey.isNotEmpty() && currentUserPrivateKey.isNotEmpty()) { - chatsListViewModel.setAccount(currentUserPublicKey, currentUserPrivateKey) - } - } - - // Состояние выпадающего меню - var showMenu by remember { mutableStateOf(false) } - var showDeleteConfirm by remember { mutableStateOf(false) } - var showBlockConfirm by remember { mutableStateOf(false) } - var showUnblockConfirm by remember { mutableStateOf(false) } - - // Проверяем, заблокирован ли пользователь (отложенно, не блокирует UI) - var isBlocked by remember { mutableStateOf(false) } - LaunchedEffect(user.publicKey, currentUserPublicKey) { - // 🔥 ОПТИМИЗАЦИЯ: Отложенная проверка - не блокирует анимацию - kotlinx.coroutines.delay(100) // Даём анимации завершиться - isBlocked = database.blacklistDao().isUserBlocked(user.publicKey, currentUserPublicKey) - } - - // Подключаем к ViewModel - val messages by viewModel.messages.collectAsState() - val inputText by viewModel.inputText.collectAsState() - val isTyping by viewModel.opponentTyping.collectAsState() - val isOnline by viewModel.opponentOnline.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона - - // 🔥 Reply/Forward state (replyMessages и hasReply определены выше для listBottomPadding) - val isForwardMode by viewModel.isForwardMode.collectAsState() - - // 🔥 Добавляем информацию о датах к сообщениям - // В reversed layout (новые внизу) - показываем дату ПОСЛЕ сообщения (визуально сверху) - val messagesWithDates = - remember(messages) { - val result = mutableListOf>() // message, showDateHeader - var lastDateString = "" - - // Сортируем по времени (новые -> старые) для reversed layout - val sortedMessages = messages.sortedByDescending { it.timestamp.time } - - for (i in sortedMessages.indices) { - val message = sortedMessages[i] - val dateString = - SimpleDateFormat("yyyyMMdd", Locale.getDefault()) - .format(message.timestamp) - - // Показываем дату если это последнее сообщение за день - // (следующее сообщение - другой день или нет следующего) - val nextMessage = sortedMessages.getOrNull(i + 1) - val nextDateString = - nextMessage?.let { - SimpleDateFormat("yyyyMMdd", Locale.getDefault()) - .format(it.timestamp) - } - val showDate = nextDateString == null || nextDateString != dateString - - result.add(message to showDate) - lastDateString = dateString - } - - result - } - - // 🔥 Функция для скролла к сообщению с подсветкой - val scrollToMessage: (String) -> Unit = { messageId -> - - // Логируем все ID сообщений для отладки - messagesWithDates.forEachIndexed { index, pair -> } - - scope.launch { - // 🔥 Сбрасываем текущую подсветку перед новым скроллом - highlightedMessageId = null - delay(50) // Небольшая задержка для сброса анимации - - // Находим индекс сообщения в списке - val messageIndex = messagesWithDates.indexOfFirst { it.first.id == messageId } - if (messageIndex != -1) { - // Скроллим к сообщению - listState.animateScrollToItem(messageIndex) - - // Подсвечиваем на 2 секунды - highlightedMessageId = messageId - delay(2000) - highlightedMessageId = null - } else {} - } - } - - // Динамический subtitle: typing > online > offline - val chatSubtitle = - when { - isSavedMessages -> "Notes" - isTyping -> "" // Пустая строка, используем компонент TypingIndicator - isOnline -> "online" - else -> "offline" - } - - // 🔥 Обработка системной кнопки назад - BackHandler { hideKeyboardAndBack() } - - // 🔥 Lifecycle-aware отслеживание активности экрана - val lifecycleOwner = LocalLifecycleOwner.current - var isScreenActive by remember { mutableStateOf(true) } - - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - isScreenActive = true - viewModel.setDialogActive(true) - } - Lifecycle.Event.ON_PAUSE -> { - isScreenActive = false - viewModel.setDialogActive(false) - } - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - viewModel.closeDialog() - } - } - - // Инициализируем ViewModel с ключами и открываем диалог - LaunchedEffect(user.publicKey) { - viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey) - viewModel.openDialog(user.publicKey, user.title, user.username) - // Подписываемся на онлайн статус собеседника - if (!isSavedMessages) { - viewModel.subscribeToOnlineStatus() - } - // 🔥 Предзагружаем эмодзи в фоне - com.rosetta.messenger.ui.components.EmojiCache.preload(context) - } - - // Отмечаем сообщения как прочитанные только когда экран активен (RESUMED) - LaunchedEffect(messages, isScreenActive) { - if (messages.isNotEmpty() && isScreenActive) { - viewModel.markVisibleMessagesAsRead() - } - } - - // Telegram-style: Прокрутка при новых сообщениях - // Всегда скроллим к последнему при изменении количества сообщений - LaunchedEffect(messages.size) { - if (messages.isNotEmpty()) { - // Всегда скроллим вниз при новом сообщении - listState.animateScrollToItem(0) - wasManualScroll = false - } - } - - // Аватар - используем publicKey для консистентности цвета везде - val avatarColors = - getAvatarColor(if (isSavedMessages) "SavedMessages" else user.publicKey, isDarkTheme) - - // � Edge swipe to go back (iOS/Telegram style) - var edgeSwipeOffset by remember { mutableStateOf(0f) } - val edgeSwipeThreshold = 100f // px threshold для активации - val edgeZoneWidth = 30f // px зона от левого края для начала свайпа - var isEdgeSwiping by remember { mutableStateOf(false) } - - // Анимация возврата - val animatedEdgeOffset by - animateFloatAsState( - targetValue = edgeSwipeOffset, - animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f), - label = "edgeSwipe" - ) - - // 🚀 Весь контент без дополнительной анимации (анимация в MainActivity) - Box( - modifier = - Modifier.fillMaxSize() - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { offset -> - // Начинаем свайп только если палец у левого края - isEdgeSwiping = offset.x < edgeZoneWidth - }, - onDragEnd = { - if (isEdgeSwiping && - edgeSwipeOffset > edgeSwipeThreshold - ) { - // Свайп достаточный - переходим назад - hideKeyboardAndBack() - } - edgeSwipeOffset = 0f - isEdgeSwiping = false - }, - onDragCancel = { - edgeSwipeOffset = 0f - isEdgeSwiping = false - }, - onHorizontalDrag = { _, dragAmount -> - if (isEdgeSwiping) { - // Только вправо (положительный dragAmount) - val newOffset = edgeSwipeOffset + dragAmount - edgeSwipeOffset = newOffset.coerceIn(0f, 300f) - } - } - ) - } - .graphicsLayer { - // Сдвигаем контент при свайпе - translationX = animatedEdgeOffset - // Легкое затемнение при свайпе - alpha = 1f - (animatedEdgeOffset / 600f).coerceIn(0f, 0.3f) - } - ) { - // Telegram-style solid header background (без blur) - val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) - - Scaffold( - contentWindowInsets = WindowInsets(0.dp), - topBar = { - // 🔥 UNIFIED HEADER - один контейнер, контент меняется внутри - Box( - modifier = - Modifier.fillMaxWidth() - .background( - if (isSelectionMode) { - if (isDarkTheme) Color(0xFF212121) - else Color.White - } else headerBackground - ) - ) { - // Контент хедера с Crossfade для плавной смены - ускоренная анимация - Crossfade( - targetState = isSelectionMode, - animationSpec = tween(150), - label = "headerContent" - ) { selectionMode -> - if (selectionMode) { - // SELECTION MODE CONTENT - Row( - modifier = - Modifier.fillMaxWidth() - .statusBarsPadding() - .height(56.dp) - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - // Left: X (cancel) + Count - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { selectedMessages = emptySet() }) { - Icon( - Icons.Default.Close, - contentDescription = "Cancel", - tint = - if (isDarkTheme) Color.White - else Color.Black, - modifier = Modifier.size(24.dp) - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Text( - "${selectedMessages.size}", - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = - if (isDarkTheme) Color.White - else Color.Black - ) - } - - // Right: Action buttons - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Copy button - IconButton( - onClick = { - val textToCopy = - messages - .filter { - selectedMessages.contains( - it.id - ) - } - .sortedBy { it.timestamp } - .joinToString("\n\n") { msg -> - val time = - SimpleDateFormat( - "HH:mm", - Locale.getDefault() - ) - .format( - msg.timestamp - ) - "[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}" - } - clipboardManager.setText( - androidx.compose.ui.text - .AnnotatedString(textToCopy) - ) - selectedMessages = emptySet() - } - ) { - Icon( - Icons.Default.ContentCopy, - contentDescription = "Copy", - tint = - if (isDarkTheme) Color.White - else Color.Black, - modifier = Modifier.size(22.dp) - ) - } - - // Delete button - IconButton( - onClick = { - messages - .filter { - selectedMessages.contains(it.id) - } - .forEach { msg -> - viewModel.deleteMessage(msg.id) - } - selectedMessages = emptySet() - } - ) { - Icon( - Icons.Default.Delete, - contentDescription = "Delete", - tint = - if (isDarkTheme) Color.White - else Color.Black, - modifier = Modifier.size(22.dp) - ) - } - } - } - } else { - // NORMAL HEADER CONTENT - Row( - modifier = - Modifier.fillMaxWidth() - .statusBarsPadding() - .height(56.dp) - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Back button with badge - Box { - IconButton( - onClick = hideKeyboardAndBack, - modifier = Modifier.size(40.dp) - ) { - Icon( - Icons.Default.KeyboardArrowLeft, - contentDescription = "Back", - tint = headerIconColor, - modifier = Modifier.size(32.dp) - ) - } - 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(Color(0xFFFF3B30)), - contentAlignment = Alignment.Center - ) { - Text( - text = - if (totalUnreadFromOthers > 99) - "99+" - else "$totalUnreadFromOthers", - color = Color.White, - fontSize = - if (totalUnreadFromOthers > 9) 9.sp - else 10.sp, - fontWeight = FontWeight.Bold, - maxLines = 1 - ) - } - } - } - - Spacer(modifier = Modifier.width(4.dp)) - - // Аватар - Box( - modifier = - Modifier.size(40.dp) - .clip(CircleShape) - .background( - if (isSavedMessages) PrimaryBlue - else - avatarColors - .backgroundColor - ) - .clickable( - indication = null, - interactionSource = - remember { - MutableInteractionSource() - } - ) { - keyboardController?.hide() - focusManager.clearFocus() - onUserProfileClick() - }, - contentAlignment = Alignment.Center - ) { - if (isSavedMessages) { - Icon( - Icons.Default.Bookmark, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(20.dp) - ) - } else { - Text( - text = - if (user.title.isNotEmpty()) - getInitials(user.title) - else user.publicKey.take(2).uppercase(), - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = avatarColors.textColor - ) - } - } - - Spacer(modifier = Modifier.width(12.dp)) - - // Информация о пользователе - Column( - modifier = - Modifier.weight(1f).clickable( - indication = null, - interactionSource = - remember { - MutableInteractionSource() - } - ) { - keyboardController?.hide() - focusManager.clearFocus() - onUserProfileClick() - } - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = chatTitle, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (!isSavedMessages && user.verified > 0) { - Spacer(modifier = Modifier.width(4.dp)) - VerifiedBadge(verified = user.verified, size = 16) - } - } - // Typing indicator или subtitle - if (isTyping) { - TypingIndicator(isDarkTheme = isDarkTheme) - } else { - Text( - text = chatSubtitle, - fontSize = 13.sp, - color = - when { - isSavedMessages -> - secondaryTextColor - isOnline -> - Color( - 0xFF38B24D - ) // Зелёный когда онлайн - else -> secondaryTextColor // Серый - // для - // offline - }, - maxLines = 1 - ) - } - } - // Кнопки действий - if (!isSavedMessages) { - IconButton(onClick = { /* TODO: Voice call */}) { - Icon( - Icons.Default.Call, - contentDescription = "Call", - tint = headerIconColor.copy(alpha = 0.6f) - ) - } - } - - // Кнопка меню - открывает kebab menu - Box { - IconButton( - onClick = { - // Закрываем клавиатуру перед открытием меню - keyboardController?.hide() - focusManager.clearFocus() - showMenu = true + // 🚀 Весь контент без дополнительной анимации (анимация в MainActivity) + Box( + modifier = + Modifier.fillMaxSize() + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { offset -> + // Начинаем свайп только если палец у левого + // края + isEdgeSwiping = offset.x < edgeZoneWidth }, - modifier = Modifier.size(48.dp).clip(CircleShape) - ) { - Icon( - Icons.Default.MoreVert, - contentDescription = "More", - tint = headerIconColor.copy(alpha = 0.6f), - modifier = Modifier.size(26.dp) - ) - } - - // 🔥 TELEGRAM-STYLE KEBAB MENU - KebabMenu( - expanded = showMenu, - onDismiss = { showMenu = false }, - isDarkTheme = isDarkTheme, - isSavedMessages = isSavedMessages, - isBlocked = isBlocked, - onBlockClick = { - showMenu = false - showBlockConfirm = true - }, - onUnblockClick = { - showMenu = false - showUnblockConfirm = true - }, - onDeleteClick = { - showMenu = false - showDeleteConfirm = true - } - ) - } - } - } - } // Закрытие Crossfade - - // Bottom line для unified header - Box( - modifier = - Modifier.align(Alignment.BottomCenter) - .fillMaxWidth() - .height(0.5.dp) - .background( - if (isDarkTheme) - Color.White.copy(alpha = 0.15f) - else Color.Black.copy(alpha = 0.1f) - ) - ) - } // Закрытие Box unified header - }, - containerColor = backgroundColor, // Фон всего чата - // 🔥 Bottom bar - инпут с умным padding: - // - Когда showEmojiPicker=false → imePadding (поднимается над клавиатурой) - // - Когда showEmojiPicker=true → НЕТ imePadding (Box с эмодзи сам даёт высоту) - bottomBar = { - // 🔥 Telegram-style: когда Box с эмодзи виден, НЕ используем imePadding - // isEmojiBoxVisible учитывает анимацию fade-out (alpha > 0.01) - // 🔥 В selection mode НЕ используем imePadding (клавиатура закрыта) - val useImePadding = !coordinator.isEmojiBoxVisible && !isSelectionMode - val bottomModifier = - if (useImePadding) { - Modifier.imePadding() // С imePadding - клавиатура поднимает инпут - } else { - Modifier // Без imePadding - } - - // Логирование состояния - LaunchedEffect( - isSelectionMode, - useImePadding, - coordinator.isEmojiBoxVisible, - coordinator.keyboardHeight - ) {} - - Column(modifier = bottomModifier) { - // 🔥 UNIFIED BOTTOM BAR - один контейнер, контент меняется внутри с плавной - // анимацией - AnimatedContent( - targetState = isSelectionMode, - transitionSpec = { - fadeIn(animationSpec = tween(200)) togetherWith - fadeOut(animationSpec = tween(150)) - }, - label = "bottomBarContent" - ) { selectionMode -> - if (selectionMode) { - // SELECTION ACTION BAR - Reply/Forward - // 🔥 Высота должна совпадать с MessageInputBar (~56dp content + nav - // bar) - 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 - плавная анимация появления - val buttonScale by - animateFloatAsState( - targetValue = if (selectionMode) 1f else 0.95f, - animationSpec = - spring( - dampingRatio = - Spring.DampingRatioMediumBouncy, - stiffness = - Spring.StiffnessMedium - ), - label = "buttonScale" - ) - - Row( - modifier = - Modifier.fillMaxWidth() - .padding( - horizontal = 12.dp, - vertical = 8.dp - ) - .navigationBarsPadding() - .graphicsLayer { - scaleX = buttonScale - scaleY = buttonScale - }, - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Reply button - идентичной высоты с инпутом - Box( - modifier = - Modifier.weight(1f) - .height(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background( - PrimaryBlue.copy( - alpha = 0.1f - ) - ) - .clickable { - val selectedMsgs = - messages - .filter { - selectedMessages - .contains( - it.id - ) - } - .sortedBy { - it.timestamp - } - viewModel.setReplyMessages( - selectedMsgs - ) - selectedMessages = emptySet() - }, - 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) - .height(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background( - PrimaryBlue.copy( - alpha = 0.1f - ) - ) - .clickable { - // 📨 Сохраняем сообщения в - // ForwardManager и показываем - // выбор чата - val selectedMsgs = - messages - .filter { - selectedMessages - .contains( - it.id - ) - } - .sortedBy { - it.timestamp - } - - val forwardMessages = - selectedMsgs.map { msg - -> - ForwardManager - .ForwardMessage( - messageId = - msg.id, - text = - msg.text, - timestamp = - msg.timestamp - .time, - isOutgoing = - msg.isOutgoing, - senderPublicKey = - if (msg.isOutgoing - ) - currentUserPublicKey - else - user.publicKey, - originalChatPublicKey = - user.publicKey - ) - } - ForwardManager - .setForwardMessages( - forwardMessages, - showPicker = - false - ) - selectedMessages = emptySet() - showForwardPicker = true - }, - 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 - ) - } - } - } - } - } else { - // INPUT BAR - Column { - MessageInputBar( - value = inputText, - onValueChange = { - viewModel.updateInputText(it) - if (it.isNotEmpty() && !isSavedMessages) { - viewModel.sendTypingIndicator() - } - }, - onSend = { - isSendingMessage = true - viewModel.sendMessage() - scope.launch { - delay(100) - listState.animateScrollToItem(0) - delay(300) - isSendingMessage = false - } - }, - isDarkTheme = isDarkTheme, - backgroundColor = backgroundColor, - textColor = textColor, - placeholderColor = secondaryTextColor, - secondaryTextColor = secondaryTextColor, - replyMessages = replyMessages, - isForwardMode = isForwardMode, - onCloseReply = { viewModel.clearReplyMessages() }, - chatTitle = chatTitle, - isBlocked = isBlocked, - showEmojiPicker = showEmojiPicker, - onToggleEmojiPicker = { showEmojiPicker = it }, - focusRequester = inputFocusRequester, - coordinator = coordinator, - displayReplyMessages = displayReplyMessages, - onReplyClick = scrollToMessage - ) - } - } - } - } // Закрытие Column с imePadding - } - ) { paddingValues -> - // 🔥 Column структура - список сжимается когда клавиатура открывается - // imePadding применён к bottomBar, поэтому контент автоматически сжимается - Column( - modifier = - Modifier.fillMaxSize() - .padding( - paddingValues - ) // 🔥 Учитываем top и bottom padding от Scaffold - .background(backgroundColor) - ) { - // Список сообщений - занимает всё доступное место - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - when { - // 🔥 СКЕЛЕТОН - показываем пока загружаются сообщения - isLoading -> { - MessageSkeletonList( - isDarkTheme = isDarkTheme, - modifier = Modifier.fillMaxSize() - ) - } - // Пустое состояние (нет сообщений) - messages.isEmpty() -> { - Column( - modifier = Modifier.fillMaxSize().padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - if (isSavedMessages) { - val composition by - rememberLottieComposition( - LottieCompositionSpec.RawRes(R.raw.saved) - ) - val progress by - animateLottieCompositionAsState( - composition = composition, - iterations = LottieConstants.IterateForever - ) - LottieAnimation( - composition = composition, - progress = { progress }, - modifier = Modifier.size(120.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 - ) - 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 - } - } - } - ), - // padding для контента списка - // 🔥 Убираем horizontal padding чтобы выделение было - // edge-to-edge - // 🔥 Увеличиваем bottom padding когда активен selection - // mode (Reply/Forward панель) - contentPadding = - PaddingValues( - start = 0.dp, - end = 0.dp, - top = 8.dp, - bottom = - if (isSelectionMode) 100.dp - else 16.dp // 🔥 Уменьшено для - // инпута - ), - reverseLayout = true - ) { - // Reversed layout: item 0 = самое новое сообщение (внизу - // экрана) - // messagesWithDates уже отсортирован новые->старые - // 🔥 Используем только id как ключ - без index, чтобы избежать - // прыгания при добавлении новых сообщений - itemsIndexed( - messagesWithDates, - key = { _, item -> item.first.id } - ) { index, (message, showDate) -> - // Определяем, показывать ли хвостик (последнее сообщение в - // группе) - val nextMessage = - messagesWithDates.getOrNull(index + 1)?.first - val showTail = - nextMessage == null || - nextMessage.isOutgoing != - message.isOutgoing || - (message.timestamp.time - - nextMessage.timestamp.time) > - 60_000 // 1 минута - - // 🚀 ОПТИМИЗАЦИЯ: animateItemPlacement() для плавной - // анимации при добавлении/удалении - // Это предотвращает "прыжки" пузырьков при изменении списка - Column( - modifier = - Modifier.animateItemPlacement( - animationSpec = - spring( - dampingRatio = - Spring.DampingRatioMediumBouncy, - stiffness = - Spring.StiffnessMedium - ) - ) - ) { - // В reversed layout: дата показывается ПОСЛЕ сообщения - // (визуально СВЕРХУ группы сообщений) - if (showDate) { - DateHeader( - dateText = - getDateText(message.timestamp.time), - secondaryTextColor = secondaryTextColor - ) - } - // 🔥 Ключ для выделения - используем только ID (как и в - // key списка) - val selectionKey = message.id - MessageBubble( - message = message, - isDarkTheme = isDarkTheme, - showTail = showTail, - isSelected = - selectedMessages.contains(selectionKey), - isHighlighted = - highlightedMessageId == message.id, - isSavedMessages = isSavedMessages, // 📁 Передаем флаг Saved Messages - onLongClick = { - - // 🔥 СНАЧАЛА закрываем клавиатуру МГНОВЕННО - // (до изменения state) - if (!isSelectionMode) { - val imm = - context.getSystemService( - Context.INPUT_METHOD_SERVICE - ) as - InputMethodManager - imm.hideSoftInputFromWindow( - view.windowToken, - 0 - ) - focusManager.clearFocus() - showEmojiPicker = false + onDragEnd = { + if (isEdgeSwiping && + edgeSwipeOffset > + edgeSwipeThreshold + ) { + // Свайп достаточный - переходим + // назад + hideKeyboardAndBack() } - // Toggle selection on long press - selectedMessages = - if (selectedMessages.contains( - selectionKey - ) + edgeSwipeOffset = 0f + isEdgeSwiping = false + }, + onDragCancel = { + edgeSwipeOffset = 0f + isEdgeSwiping = false + }, + onHorizontalDrag = { _, dragAmount -> + if (isEdgeSwiping) { + // Только вправо (положительный + // dragAmount) + val newOffset = + edgeSwipeOffset + dragAmount + edgeSwipeOffset = + newOffset.coerceIn(0f, 300f) + } + } + ) + } + .graphicsLayer { + // Сдвигаем контент при свайпе + translationX = animatedEdgeOffset + // Легкое затемнение при свайпе + alpha = 1f - (animatedEdgeOffset / 600f).coerceIn(0f, 0.3f) + } + ) { + // Telegram-style solid header background (без blur) + val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) + + Scaffold( + contentWindowInsets = WindowInsets(0.dp), + topBar = { + // 🔥 UNIFIED HEADER - один контейнер, контент меняется внутри + Box( + modifier = + Modifier.fillMaxWidth() + .background( + if (isSelectionMode) { + if (isDarkTheme) + Color(0xFF212121) + else Color.White + } else headerBackground + ) + ) { + // Контент хедера с Crossfade для плавной смены - ускоренная + // анимация + Crossfade( + targetState = isSelectionMode, + animationSpec = tween(150), + label = "headerContent" + ) { selectionMode -> + if (selectionMode) { + // SELECTION MODE CONTENT + Row( + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .height(56.dp) + .padding( + horizontal = + 4.dp + ), + verticalAlignment = + Alignment.CenterVertically, + horizontalArrangement = + Arrangement.SpaceBetween + ) { + // Left: X (cancel) + Count + Row( + verticalAlignment = + Alignment + .CenterVertically ) { - selectedMessages - selectionKey - } else { - selectedMessages + selectionKey + IconButton( + onClick = { + selectedMessages = + emptySet() + } + ) { + Icon( + Icons.Default + .Close, + contentDescription = + "Cancel", + tint = + if (isDarkTheme + ) + Color.White + else + Color.Black, + modifier = + Modifier.size( + 24.dp + ) + ) + } + Spacer( + modifier = + Modifier.width( + 8.dp + ) + ) + Text( + "${selectedMessages.size}", + fontSize = 20.sp, + fontWeight = + FontWeight + .Bold, + color = + if (isDarkTheme + ) + Color.White + else + Color.Black + ) + } + + // Right: Action buttons + Row( + horizontalArrangement = + Arrangement + .spacedBy( + 4.dp + ), + verticalAlignment = + Alignment + .CenterVertically + ) { + // Copy button + IconButton( + onClick = { + val textToCopy = + messages + .filter { + selectedMessages + .contains( + it.id + ) + } + .sortedBy { + it.timestamp + } + .joinToString( + "\n\n" + ) { + msg + -> + val time = + SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ) + .format( + msg.timestamp + ) + "[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}" + } + clipboardManager + .setText( + androidx.compose + .ui + .text + .AnnotatedString( + textToCopy + ) + ) + selectedMessages = + emptySet() + } + ) { + Icon( + Icons.Default + .ContentCopy, + contentDescription = + "Copy", + tint = + if (isDarkTheme + ) + Color.White + else + Color.Black, + modifier = + Modifier.size( + 22.dp + ) + ) + } + + // Delete button + IconButton( + onClick = { + messages + .filter { + selectedMessages + .contains( + it.id + ) + } + .forEach { + msg + -> + viewModel + .deleteMessage( + msg.id + ) + } + selectedMessages = + emptySet() + } + ) { + Icon( + Icons.Default + .Delete, + contentDescription = + "Delete", + tint = + if (isDarkTheme + ) + Color.White + else + Color.Black, + modifier = + Modifier.size( + 22.dp + ) + ) + } } - }, - onClick = { - // If in selection mode, toggle selection - if (isSelectionMode) { - selectedMessages = - if (selectedMessages.contains( - selectionKey - ) - ) { - selectedMessages - - selectionKey - } else { - selectedMessages + - selectionKey - } } - }, - onSwipeToReply = { - // 🔥 Swipe-to-reply: добавляем это - // сообщение в reply - viewModel.setReplyMessages(listOf(message)) - }, - onReplyClick = { messageId -> - // 🔥 Клик на цитату - скроллим к сообщению - scrollToMessage(messageId) - }, - onRetry = { - // 🔥 Retry: удаляем старое и отправляем - // заново - viewModel.retryMessage(message) - }, - onDelete = { - // 🔥 Delete: удаляем сообщение - viewModel.deleteMessage(message.id) - } - ) - } - } - } - } - } - } - } - } // Закрытие Box с fade-in - - // Диалог подтверждения удаления чата - if (showDeleteConfirm) { - AlertDialog( - onDismissRequest = { showDeleteConfirm = false }, - containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, - title = { Text("Delete Chat", fontWeight = FontWeight.Bold, color = textColor) }, - text = { - Text( - "Are you sure you want to delete this chat? This action cannot be undone.", - color = secondaryTextColor - ) - }, - confirmButton = { - TextButton( - onClick = { - showDeleteConfirm = false - scope.launch { - try { - android.util.Log.d( - "ChatDetail", - "🗑️ ========== DELETE CHAT START ==========" - ) - android.util.Log.d( - "ChatDetail", - "🗑️ currentUserPublicKey=${currentUserPublicKey}" - ) - android.util.Log.d( - "ChatDetail", - "🗑️ user.publicKey=${user.publicKey}" - ) - - // Вычисляем правильный dialog_key (отсортированная - // комбинация ключей) - val dialogKey = - if (currentUserPublicKey < user.publicKey) { - "$currentUserPublicKey:${user.publicKey}" } else { - "${user.publicKey}:$currentUserPublicKey" - } - android.util.Log.d("ChatDetail", "🗑️ dialogKey=$dialogKey") + // NORMAL HEADER CONTENT + Row( + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .height(56.dp) + .padding( + horizontal = + 4.dp + ), + verticalAlignment = + Alignment.CenterVertically + ) { + // Back button with badge + Box { + IconButton( + onClick = + hideKeyboardAndBack, + modifier = + Modifier.size( + 40.dp + ) + ) { + Icon( + Icons.Default + .KeyboardArrowLeft, + contentDescription = + "Back", + tint = + headerIconColor, + modifier = + Modifier.size( + 32.dp + ) + ) + } + 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( + Color( + 0xFFFF3B30 + ) + ), + contentAlignment = + Alignment + .Center + ) { + Text( + text = + if (totalUnreadFromOthers > + 99 + ) + "99+" + else + "$totalUnreadFromOthers", + color = + Color.White, + fontSize = + if (totalUnreadFromOthers > + 9 + ) + 9.sp + else + 10.sp, + fontWeight = + FontWeight + .Bold, + maxLines = + 1 + ) + } + } + } - // 🗑️ Очищаем ВСЕ кэши сообщений - com.rosetta.messenger.data.MessageRepository.getInstance( - context - ) - .clearDialogCache(user.publicKey) - // 🗑️ Очищаем кэш ChatViewModel - ChatViewModel.clearCacheForOpponent(user.publicKey) - - // Проверяем количество сообщений до удаления - val countBefore = - database.messageDao() - .getMessageCount( - currentUserPublicKey, - dialogKey - ) - android.util.Log.d( - "ChatDetail", - "🗑️ Messages BEFORE delete: $countBefore" - ) - - // Удаляем все сообщения из диалога по dialog_key - val deletedByKey = - database.messageDao() - .deleteDialog( - account = currentUserPublicKey, - dialogKey = dialogKey - ) - android.util.Log.d( - "ChatDetail", - "🗑️ Deleted by dialogKey: $deletedByKey" - ) - - // Также пробуем удалить по from/to ключам (на всякий - // случай) - val deletedBetween = - database.messageDao() - .deleteMessagesBetweenUsers( - account = currentUserPublicKey, - user1 = user.publicKey, - user2 = currentUserPublicKey - ) - android.util.Log.d( - "ChatDetail", - "🗑️ Deleted between users: $deletedBetween" - ) - - // Проверяем количество сообщений после удаления - val countAfter = - database.messageDao() - .getMessageCount( - currentUserPublicKey, - dialogKey - ) - android.util.Log.d( - "ChatDetail", - "🗑️ Messages AFTER delete: $countAfter" - ) - - // Очищаем кеш диалога - database.dialogDao() - .deleteDialog( - account = currentUserPublicKey, - opponentKey = user.publicKey - ) - android.util.Log.d( - "ChatDetail", - "🗑️ Dialog deleted from DB" - ) - - // Проверяем что диалог удален - val dialogAfter = - database.dialogDao() - .getDialog( - currentUserPublicKey, - user.publicKey - ) - android.util.Log.d( - "ChatDetail", - "🗑️ Dialog after: ${dialogAfter?.opponentKey ?: "NULL (deleted)"}" - ) - android.util.Log.d( - "ChatDetail", - "🗑️ ========== DELETE CHAT COMPLETE ==========" - ) - } catch (e: Exception) { - android.util.Log.e( - "ChatDetail", - "🗑️ DELETE ERROR: ${e.message}", - e - ) - } - // Выходим ПОСЛЕ удаления - закрываем клавиатуру - hideKeyboardAndBack() - } - } - ) { Text("Delete", color = Color(0xFFFF3B30)) } - }, - dismissButton = { - TextButton(onClick = { showDeleteConfirm = false }) { - Text("Cancel", color = PrimaryBlue) - } - } - ) - } - - // Диалог подтверждения блокировки - if (showBlockConfirm) { - AlertDialog( - onDismissRequest = { showBlockConfirm = false }, - containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, - title = { - Text( - "Block ${user.title.ifEmpty { "User" }}", - fontWeight = FontWeight.Bold, - color = textColor - ) - }, - text = { - Text( - "Are you sure you want to block this user? They won't be able to send you messages.", - color = secondaryTextColor - ) - }, - confirmButton = { - TextButton( - onClick = { - showBlockConfirm = false - scope.launch { - try { - // Добавляем пользователя в blacklist - database.blacklistDao() - .blockUser( - com.rosetta.messenger.database - .BlacklistEntity( - publicKey = user.publicKey, - account = - currentUserPublicKey + Spacer( + modifier = + Modifier.width(4.dp) ) - ) - isBlocked = true - } catch (e: Exception) { - // Error blocking user - } - } - } - ) { Text("Block", color = Color(0xFFFF3B30)) } - }, - dismissButton = { - TextButton(onClick = { showBlockConfirm = false }) { - Text("Cancel", color = PrimaryBlue) - } - } - ) - } - // Диалог подтверждения разблокировки - if (showUnblockConfirm) { - AlertDialog( - onDismissRequest = { showUnblockConfirm = false }, - containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, - title = { - Text( - "Unblock ${user.title.ifEmpty { "User" }}", - fontWeight = FontWeight.Bold, - color = textColor - ) - }, - text = { - Text( - "Are you sure you want to unblock this user? They will be able to send you messages again.", - color = secondaryTextColor - ) - }, - confirmButton = { - TextButton( - onClick = { - showUnblockConfirm = false - scope.launch { - try { - // Удаляем пользователя из blacklist - database.blacklistDao() - .unblockUser( - publicKey = user.publicKey, - account = currentUserPublicKey - ) - isBlocked = false - } catch (e: Exception) { - // Error unblocking user - } - } - } - ) { Text("Unblock", color = PrimaryBlue) } - }, - dismissButton = { - TextButton(onClick = { showUnblockConfirm = false }) { - Text("Cancel", color = Color(0xFF8E8E93)) - } - } - ) - } + // Аватар + Box( + modifier = + Modifier.size(40.dp) + .clip( + CircleShape + ) + .background( + if (isSavedMessages + ) + PrimaryBlue + else + avatarColors + .backgroundColor + ) + .clickable( + indication = + null, + interactionSource = + remember { + MutableInteractionSource() + } + ) { + keyboardController + ?.hide() + focusManager + .clearFocus() + onUserProfileClick() + }, + contentAlignment = + Alignment.Center + ) { + if (isSavedMessages) { + Icon( + Icons.Default + .Bookmark, + contentDescription = + null, + tint = + Color.White, + modifier = + Modifier.size( + 20.dp + ) + ) + } else { + Text( + text = + if (user.title + .isNotEmpty() + ) + getInitials( + user.title + ) + else + user.publicKey + .take( + 2 + ) + .uppercase(), + fontSize = + 14.sp, + fontWeight = + FontWeight + .Bold, + color = + avatarColors + .textColor + ) + } + } - // 📨 Forward Chat Picker BottomSheet - if (showForwardPicker) { - ForwardChatPickerBottomSheet( - dialogs = dialogsList, - isDarkTheme = isDarkTheme, - currentUserPublicKey = currentUserPublicKey, - onDismiss = { - showForwardPicker = false - ForwardManager.clear() - }, - onChatSelected = { selectedPublicKey -> - showForwardPicker = false - // Переходим в выбранный чат - ForwardManager.selectChat(selectedPublicKey) - onNavigateToChat(selectedPublicKey) + Spacer( + modifier = + Modifier.width( + 12.dp + ) + ) + + // Информация о пользователе + Column( + modifier = + Modifier.weight(1f) + .clickable( + indication = + null, + interactionSource = + remember { + MutableInteractionSource() + } + ) { + keyboardController + ?.hide() + focusManager + .clearFocus() + onUserProfileClick() + } + ) { + Row( + verticalAlignment = + Alignment + .CenterVertically + ) { + Text( + text = + chatTitle, + fontSize = + 16.sp, + fontWeight = + FontWeight + .SemiBold, + color = + textColor, + maxLines = + 1, + overflow = + TextOverflow + .Ellipsis + ) + if (!isSavedMessages && + user.verified > + 0 + ) { + Spacer( + modifier = + Modifier.width( + 4.dp + ) + ) + VerifiedBadge( + verified = + user.verified, + size = + 16 + ) + } + } + // Typing indicator или + // subtitle + if (isTyping) { + TypingIndicator( + isDarkTheme = + isDarkTheme + ) + } else { + Text( + text = + chatSubtitle, + fontSize = + 13.sp, + color = + when { + isSavedMessages -> + secondaryTextColor + isOnline -> + Color( + 0xFF38B24D + ) // Зелёный когда онлайн + else -> + secondaryTextColor // Серый + // для + // offline + }, + maxLines = 1 + ) + } + } + // Кнопки действий + if (!isSavedMessages) { + IconButton( + onClick = { /* TODO: Voice call */ + } + ) { + Icon( + Icons.Default + .Call, + contentDescription = + "Call", + tint = + headerIconColor + .copy( + alpha = + 0.6f + ) + ) + } + } + + // Кнопка меню - открывает kebab + // menu + Box { + IconButton( + onClick = { + // Закрываем + // клавиатуру перед открытием меню + keyboardController + ?.hide() + focusManager + .clearFocus() + showMenu = + true + }, + modifier = + Modifier.size( + 48.dp + ) + .clip( + CircleShape + ) + ) { + Icon( + Icons.Default + .MoreVert, + contentDescription = + "More", + tint = + headerIconColor + .copy( + alpha = + 0.6f + ), + modifier = + Modifier.size( + 26.dp + ) + ) + } + + // 🔥 TELEGRAM-STYLE KEBAB + // MENU + KebabMenu( + expanded = showMenu, + onDismiss = { + showMenu = + false + }, + isDarkTheme = + isDarkTheme, + isSavedMessages = + isSavedMessages, + isBlocked = + isBlocked, + onBlockClick = { + showMenu = + false + showBlockConfirm = + true + }, + onUnblockClick = { + showMenu = + false + showUnblockConfirm = + true + }, + onDeleteClick = { + showMenu = + false + showDeleteConfirm = + true + } + ) + } + } + } + } // Закрытие Crossfade + + // Bottom line для unified header + Box( + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) + Color.White.copy( + alpha = + 0.15f + ) + else + Color.Black.copy( + alpha = 0.1f + ) + ) + ) + } // Закрытие Box unified header + }, + containerColor = backgroundColor, // Фон всего чата + // 🔥 Bottom bar - инпут с умным padding: + // - Когда showEmojiPicker=false → imePadding (поднимается над клавиатурой) + // - Когда showEmojiPicker=true → НЕТ imePadding (Box с эмодзи сам даёт + // высоту) + bottomBar = { + // 🔥 Telegram-style: когда Box с эмодзи виден, НЕ используем + // imePadding + // isEmojiBoxVisible учитывает анимацию fade-out (alpha > 0.01) + // 🔥 В selection mode НЕ используем imePadding (клавиатура закрыта) + val useImePadding = + !coordinator.isEmojiBoxVisible && !isSelectionMode + val bottomModifier = + if (useImePadding) { + Modifier.imePadding() // С imePadding - клавиатура + // поднимает инпут + } else { + Modifier // Без imePadding + } + + // Логирование состояния + LaunchedEffect( + isSelectionMode, + useImePadding, + coordinator.isEmojiBoxVisible, + coordinator.keyboardHeight + ) {} + + Column(modifier = bottomModifier) { + // 🔥 UNIFIED BOTTOM BAR - один контейнер, контент меняется + // внутри с плавной + // анимацией + AnimatedContent( + targetState = isSelectionMode, + transitionSpec = { + fadeIn( + animationSpec = tween(200) + ) togetherWith + fadeOut(animationSpec = tween(150)) + }, + label = "bottomBarContent" + ) { selectionMode -> + if (selectionMode) { + // SELECTION ACTION BAR - Reply/Forward + // 🔥 Высота должна совпадать с + // MessageInputBar (~56dp content + nav + // bar) + 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 - плавная + // анимация появления + val buttonScale by + animateFloatAsState( + targetValue = + if (selectionMode + ) + 1f + else 0.95f, + animationSpec = + spring( + dampingRatio = + Spring.DampingRatioMediumBouncy, + stiffness = + Spring.StiffnessMedium + ), + label = + "buttonScale" + ) + + Row( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = + 12.dp, + vertical = + 8.dp + ) + .navigationBarsPadding() + .graphicsLayer { + scaleX = + buttonScale + scaleY = + buttonScale + }, + horizontalArrangement = + Arrangement + .spacedBy( + 12.dp + ), + verticalAlignment = + Alignment + .CenterVertically + ) { + // Reply button - идентичной + // высоты с инпутом + Box( + modifier = + Modifier.weight( + 1f + ) + .height( + 48.dp + ) + .clip( + RoundedCornerShape( + 12.dp + ) + ) + .background( + PrimaryBlue + .copy( + alpha = + 0.1f + ) + ) + .clickable { + val selectedMsgs = + messages + .filter { + selectedMessages + .contains( + it.id + ) + } + .sortedBy { + it.timestamp + } + viewModel + .setReplyMessages( + selectedMsgs + ) + selectedMessages = + emptySet() + }, + 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 + ) + .height( + 48.dp + ) + .clip( + RoundedCornerShape( + 12.dp + ) + ) + .background( + PrimaryBlue + .copy( + alpha = + 0.1f + ) + ) + .clickable { + // 📨 Сохраняем сообщения в + // ForwardManager и показываем + // выбор чата + val selectedMsgs = + messages + .filter { + selectedMessages + .contains( + it.id + ) + } + .sortedBy { + it.timestamp + } + + val forwardMessages = + selectedMsgs + .map { + msg + -> + ForwardManager + .ForwardMessage( + messageId = + msg.id, + text = + msg.text, + timestamp = + msg.timestamp + .time, + isOutgoing = + msg.isOutgoing, + senderPublicKey = + if (msg.isOutgoing + ) + currentUserPublicKey + else + user.publicKey, + originalChatPublicKey = + user.publicKey + ) + } + ForwardManager + .setForwardMessages( + forwardMessages, + showPicker = + false + ) + selectedMessages = + emptySet() + showForwardPicker = + true + }, + 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 + ) + } + } + } + } + } else { + // INPUT BAR + Column { + MessageInputBar( + value = inputText, + onValueChange = { + viewModel + .updateInputText( + it + ) + if (it.isNotEmpty() && + !isSavedMessages + ) { + viewModel + .sendTypingIndicator() + } + }, + onSend = { + isSendingMessage = + true + viewModel + .sendMessage() + scope.launch { + delay(100) + listState + .animateScrollToItem( + 0 + ) + delay(300) + isSendingMessage = + false + } + }, + isDarkTheme = isDarkTheme, + backgroundColor = + backgroundColor, + textColor = textColor, + placeholderColor = + secondaryTextColor, + secondaryTextColor = + secondaryTextColor, + replyMessages = + replyMessages, + isForwardMode = + isForwardMode, + onCloseReply = { + viewModel + .clearReplyMessages() + }, + chatTitle = chatTitle, + isBlocked = isBlocked, + showEmojiPicker = + showEmojiPicker, + onToggleEmojiPicker = { + showEmojiPicker = it + }, + focusRequester = + inputFocusRequester, + coordinator = coordinator, + displayReplyMessages = + displayReplyMessages, + onReplyClick = + scrollToMessage + ) + } + } + } + } // Закрытие Column с imePadding + } + ) { paddingValues -> + // 🔥 Column структура - список сжимается когда клавиатура открывается + // imePadding применён к bottomBar, поэтому контент автоматически сжимается + Column( + modifier = + Modifier.fillMaxSize() + .padding( + paddingValues + ) // 🔥 Учитываем top и bottom padding от Scaffold + .background(backgroundColor) + ) { + // Список сообщений - занимает всё доступное место + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + when { + // 🔥 СКЕЛЕТОН - показываем пока загружаются + // сообщения + isLoading -> { + MessageSkeletonList( + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + } + // Пустое состояние (нет сообщений) + messages.isEmpty() -> { + Column( + modifier = + Modifier.fillMaxSize() + .padding(32.dp), + horizontalAlignment = + Alignment + .CenterHorizontally, + verticalArrangement = + Arrangement.Center + ) { + if (isSavedMessages) { + val composition by + rememberLottieComposition( + LottieCompositionSpec + .RawRes( + R.raw.saved + ) + ) + val progress by + animateLottieCompositionAsState( + composition = + composition, + iterations = + LottieConstants + .IterateForever + ) + LottieAnimation( + composition = + composition, + progress = { + progress + }, + modifier = + Modifier.size( + 120.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 + ) + 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 + } + } + } + ), + // padding для контента списка + // 🔥 Убираем horizontal padding + // чтобы выделение было + // edge-to-edge + // 🔥 Увеличиваем bottom padding + // когда активен selection + // mode (Reply/Forward панель) + contentPadding = + PaddingValues( + start = 0.dp, + end = 0.dp, + top = 8.dp, + bottom = + if (isSelectionMode + ) + 100.dp + else + 16.dp // 🔥 Уменьшено для + // инпута + ), + reverseLayout = true + ) { + // Reversed layout: item 0 = самое + // новое сообщение (внизу + // экрана) + // messagesWithDates уже + // отсортирован новые->старые + // 🔥 Используем только id как ключ + // - без index, чтобы избежать + // прыгания при добавлении новых + // сообщений + itemsIndexed( + messagesWithDates, + key = { _, item -> + item.first.id + } + ) { index, (message, showDate) -> + // Определяем, показывать ли + // хвостик (последнее + // сообщение в + // группе) + val nextMessage = + messagesWithDates + .getOrNull( + index + + 1 + ) + ?.first + val showTail = + nextMessage == + null || + nextMessage + .isOutgoing != + message.isOutgoing || + (message.timestamp + .time - + nextMessage + .timestamp + .time) > + 60_000 // 1 минута + + // 🚀 ОПТИМИЗАЦИЯ: + // animateItemPlacement() + // для плавной + // анимации при + // добавлении/удалении + // Это предотвращает + // "прыжки" пузырьков при + // изменении списка + Column( + modifier = + Modifier.animateItemPlacement( + animationSpec = + spring( + dampingRatio = + Spring.DampingRatioMediumBouncy, + stiffness = + Spring.StiffnessMedium + ) + ) + ) { + // В reversed + // layout: дата + // показывается + // ПОСЛЕ сообщения + // (визуально СВЕРХУ + // группы сообщений) + if (showDate) { + DateHeader( + dateText = + getDateText( + message.timestamp + .time + ), + secondaryTextColor = + secondaryTextColor + ) + } + // 🔥 Ключ для + // выделения - + // используем только + // ID (как и в + // key списка) + val selectionKey = + message.id + MessageBubble( + message = + message, + isDarkTheme = + isDarkTheme, + showTail = + showTail, + isSelected = + selectedMessages + .contains( + selectionKey + ), + isHighlighted = + highlightedMessageId == + message.id, + isSavedMessages = + isSavedMessages, // 📁 Передаем флаг Saved Messages + onLongClick = { + + // 🔥 СНАЧАЛА закрываем клавиатуру МГНОВЕННО + // (до изменения state) + if (!isSelectionMode + ) { + val imm = + context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as + InputMethodManager + imm.hideSoftInputFromWindow( + view.windowToken, + 0 + ) + focusManager + .clearFocus() + showEmojiPicker = + false + } + // Toggle selection on long press + selectedMessages = + if (selectedMessages + .contains( + selectionKey + ) + ) { + selectedMessages - + selectionKey + } else { + selectedMessages + + selectionKey + } + }, + onClick = { + // If in selection mode, toggle selection + if (isSelectionMode + ) { + selectedMessages = + if (selectedMessages + .contains( + selectionKey + ) + ) { + selectedMessages - + selectionKey + } else { + selectedMessages + + selectionKey + } + } + }, + onSwipeToReply = { + // 🔥 Swipe-to-reply: добавляем это + // сообщение в reply + viewModel + .setReplyMessages( + listOf( + message + ) + ) + }, + onReplyClick = { + messageId + -> + // 🔥 Клик на цитату - скроллим к сообщению + scrollToMessage( + messageId + ) + }, + onRetry = { + // 🔥 Retry: удаляем старое и отправляем + // заново + viewModel + .retryMessage( + message + ) + }, + onDelete = { + // 🔥 Delete: удаляем сообщение + viewModel + .deleteMessage( + message.id + ) + } + ) + } + } + } + } + } + } } - ) - } + } // Закрытие Box с fade-in + + // Диалог подтверждения удаления чата + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text("Delete Chat", fontWeight = FontWeight.Bold, color = textColor) + }, + text = { + Text( + "Are you sure you want to delete this chat? This action cannot be undone.", + color = secondaryTextColor + ) + }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirm = false + scope.launch { + try { + android.util.Log.d( + "ChatDetail", + "🗑️ ========== DELETE CHAT START ==========" + ) + android.util.Log.d( + "ChatDetail", + "🗑️ currentUserPublicKey=${currentUserPublicKey}" + ) + android.util.Log.d( + "ChatDetail", + "🗑️ user.publicKey=${user.publicKey}" + ) + + // Вычисляем правильный dialog_key + // (отсортированная + // комбинация ключей) + val dialogKey = + if (currentUserPublicKey < + user.publicKey + ) { + "$currentUserPublicKey:${user.publicKey}" + } else { + "${user.publicKey}:$currentUserPublicKey" + } + android.util.Log.d( + "ChatDetail", + "🗑️ dialogKey=$dialogKey" + ) + + // 🗑️ Очищаем ВСЕ кэши сообщений + com.rosetta.messenger.data + .MessageRepository + .getInstance(context) + .clearDialogCache( + user.publicKey + ) + // 🗑️ Очищаем кэш ChatViewModel + ChatViewModel.clearCacheForOpponent( + user.publicKey + ) + + // Проверяем количество сообщений до + // удаления + val countBefore = + database.messageDao() + .getMessageCount( + currentUserPublicKey, + dialogKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Messages BEFORE delete: $countBefore" + ) + + // Удаляем все сообщения из диалога + // по dialog_key + val deletedByKey = + database.messageDao() + .deleteDialog( + account = + currentUserPublicKey, + dialogKey = + dialogKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Deleted by dialogKey: $deletedByKey" + ) + + // Также пробуем удалить по from/to + // ключам (на всякий + // случай) + val deletedBetween = + database.messageDao() + .deleteMessagesBetweenUsers( + account = + currentUserPublicKey, + user1 = + user.publicKey, + user2 = + currentUserPublicKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Deleted between users: $deletedBetween" + ) + + // Проверяем количество сообщений + // после удаления + val countAfter = + database.messageDao() + .getMessageCount( + currentUserPublicKey, + dialogKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Messages AFTER delete: $countAfter" + ) + + // Очищаем кеш диалога + database.dialogDao() + .deleteDialog( + account = + currentUserPublicKey, + opponentKey = + user.publicKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Dialog deleted from DB" + ) + + // Проверяем что диалог удален + val dialogAfter = + database.dialogDao() + .getDialog( + currentUserPublicKey, + user.publicKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Dialog after: ${dialogAfter?.opponentKey ?: "NULL (deleted)"}" + ) + android.util.Log.d( + "ChatDetail", + "🗑️ ========== DELETE CHAT COMPLETE ==========" + ) + } catch (e: Exception) { + android.util.Log.e( + "ChatDetail", + "🗑️ DELETE ERROR: ${e.message}", + e + ) + } + // Выходим ПОСЛЕ удаления - закрываем + // клавиатуру + hideKeyboardAndBack() + } + } + ) { Text("Delete", color = Color(0xFFFF3B30)) } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text("Cancel", color = PrimaryBlue) + } + } + ) + } + + // Диалог подтверждения блокировки + if (showBlockConfirm) { + AlertDialog( + onDismissRequest = { showBlockConfirm = false }, + containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text( + "Block ${user.title.ifEmpty { "User" }}", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + Text( + "Are you sure you want to block this user? They won't be able to send you messages.", + color = secondaryTextColor + ) + }, + confirmButton = { + TextButton( + onClick = { + showBlockConfirm = false + scope.launch { + try { + // Добавляем пользователя в + // blacklist + database.blacklistDao() + .blockUser( + com.rosetta + .messenger + .database + .BlacklistEntity( + publicKey = + user.publicKey, + account = + currentUserPublicKey + ) + ) + isBlocked = true + } catch (e: Exception) { + // Error blocking user + } + } + } + ) { Text("Block", color = Color(0xFFFF3B30)) } + }, + dismissButton = { + TextButton(onClick = { showBlockConfirm = false }) { + Text("Cancel", color = PrimaryBlue) + } + } + ) + } + + // Диалог подтверждения разблокировки + if (showUnblockConfirm) { + AlertDialog( + onDismissRequest = { showUnblockConfirm = false }, + containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text( + "Unblock ${user.title.ifEmpty { "User" }}", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + Text( + "Are you sure you want to unblock this user? They will be able to send you messages again.", + color = secondaryTextColor + ) + }, + confirmButton = { + TextButton( + onClick = { + showUnblockConfirm = false + scope.launch { + try { + // Удаляем пользователя из blacklist + database.blacklistDao() + .unblockUser( + publicKey = + user.publicKey, + account = + currentUserPublicKey + ) + isBlocked = false + } catch (e: Exception) { + // Error unblocking user + } + } + } + ) { Text("Unblock", color = PrimaryBlue) } + }, + dismissButton = { + TextButton(onClick = { showUnblockConfirm = false }) { + Text("Cancel", color = Color(0xFF8E8E93)) + } + } + ) + } + + // 📨 Forward Chat Picker BottomSheet + if (showForwardPicker) { + ForwardChatPickerBottomSheet( + dialogs = dialogsList, + isDarkTheme = isDarkTheme, + currentUserPublicKey = currentUserPublicKey, + onDismiss = { + showForwardPicker = false + ForwardManager.clear() + }, + onChatSelected = { selectedPublicKey -> + showForwardPicker = false + // Переходим в выбранный чат + ForwardManager.selectChat(selectedPublicKey) + onNavigateToChat(selectedPublicKey) + } + ) + } } /** 🚀 Анимация появления сообщения Telegram-style */ @Composable fun rememberMessageEnterAnimation(messageId: String): Pair { - var animationPlayed by remember(messageId) { mutableStateOf(false) } + var animationPlayed by remember(messageId) { mutableStateOf(false) } - val alpha by - animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = tween(durationMillis = 250, easing = TelegramEasing), - label = "messageAlpha" - ) + val alpha by + animateFloatAsState( + targetValue = if (animationPlayed) 1f else 0f, + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), + label = "messageAlpha" + ) - val translationY by - animateFloatAsState( - targetValue = if (animationPlayed) 0f else 20f, - animationSpec = tween(durationMillis = 250, easing = TelegramEasing), - label = "messageTranslationY" - ) + val translationY by + animateFloatAsState( + targetValue = if (animationPlayed) 0f else 20f, + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), + label = "messageTranslationY" + ) - LaunchedEffect(messageId) { - delay(16) // One frame delay - animationPlayed = true - } + LaunchedEffect(messageId) { + delay(16) // One frame delay + animationPlayed = true + } - return Pair(alpha, translationY) + return Pair(alpha, translationY) } /** 🚀 Пузырек сообщения Telegram-style - ОПТИМИЗИРОВАННЫЙ */ @@ -1765,289 +2331,323 @@ private fun MessageBubble( onRetry: () -> Unit = {}, // 🔥 Retry для ошибки onDelete: () -> Unit = {} // 🔥 Delete для ошибки ) { - // 🔥 Swipe-to-reply state (как в Telegram) - var swipeOffset by remember { mutableStateOf(0f) } - val swipeThreshold = 80f // dp порог для активации reply - val maxSwipe = 120f // Максимальный сдвиг + // 🔥 Swipe-to-reply state (как в Telegram) + var swipeOffset by remember { mutableStateOf(0f) } + val swipeThreshold = 80f // dp порог для активации reply + val maxSwipe = 120f // Максимальный сдвиг - // Анимация возврата - val animatedOffset by - animateFloatAsState( - targetValue = swipeOffset, - animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), - label = "swipeOffset" - ) - - // Прогресс свайпа для иконки reply (0..1) - используем абсолютное значение т.к. свайп влево - val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f) - - // Selection animation - только если нужно - val selectionScale by - animateFloatAsState( - targetValue = if (isSelected) 0.95f else 1f, - animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), - label = "selectionScale" - ) - val selectionAlpha by - animateFloatAsState( - targetValue = if (isSelected) 0.85f else 1f, - animationSpec = tween(150), - label = "selectionAlpha" - ) - - // 🔥 Цвета - НАШИ ОРИГИНАЛЬНЫЕ - val bubbleColor = - remember(message.isOutgoing, isDarkTheme) { - if (message.isOutgoing) { - PrimaryBlue // Исходящие - наш синий - } else { - // Входящие - наши цвета - if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) - } - } - val textColor = - remember(message.isOutgoing, isDarkTheme) { - if (message.isOutgoing) Color.White - else if (isDarkTheme) Color.White else Color(0xFF000000) - } - // Время - наши оригинальные цвета - val timeColor = - remember(message.isOutgoing, isDarkTheme) { - if (message.isOutgoing) Color.White.copy(alpha = 0.7f) - else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - } - - // 🔥 TELEGRAM STYLE: Форма пузырька - более мягкие углы - val bubbleShape = - remember(message.isOutgoing, showTail) { - RoundedCornerShape( - topStart = 16.dp, - topEnd = 16.dp, - // Хвостик: маленький радиус (4dp) только у нижнего угла со стороны - // отправителя - bottomStart = - if (message.isOutgoing) 16.dp else (if (showTail) 4.dp else 16.dp), - bottomEnd = - if (message.isOutgoing) (if (showTail) 4.dp else 16.dp) else 16.dp + // Анимация возврата + val animatedOffset by + animateFloatAsState( + targetValue = swipeOffset, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), + label = "swipeOffset" ) - } - // 🔥 ОПТИМИЗАЦИЯ: SimpleDateFormat создаём один раз - val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + // Прогресс свайпа для иконки reply (0..1) - используем абсолютное значение т.к. свайп влево + val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f) - // 🔥 Swipe-to-reply wrapper - Box( - modifier = - Modifier.fillMaxWidth().pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - // Если свайп достиг порога - активируем reply - if (swipeOffset <= -swipeThreshold) { - onSwipeToReply() - } - // Возвращаем на место - swipeOffset = 0f - }, - onDragCancel = { swipeOffset = 0f }, - onHorizontalDrag = { _, dragAmount -> - // Только свайп влево (отрицательный dragAmount) - val newOffset = swipeOffset + dragAmount - swipeOffset = newOffset.coerceIn(-maxSwipe, 0f) - } + // Selection animation - только если нужно + val selectionScale by + animateFloatAsState( + targetValue = if (isSelected) 0.95f else 1f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), + label = "selectionScale" + ) + val selectionAlpha by + animateFloatAsState( + targetValue = if (isSelected) 0.85f else 1f, + animationSpec = tween(150), + label = "selectionAlpha" + ) + + // 🔥 Цвета - НАШИ ОРИГИНАЛЬНЫЕ + val bubbleColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) { + PrimaryBlue // Исходящие - наш синий + } else { + // Входящие - наши цвета + if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) + } + } + val textColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) Color.White + else if (isDarkTheme) Color.White else Color(0xFF000000) + } + // Время - наши оригинальные цвета + val timeColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) Color.White.copy(alpha = 0.7f) + else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + } + + // 🔥 TELEGRAM STYLE: Форма пузырька - более мягкие углы + val bubbleShape = + remember(message.isOutgoing, showTail) { + RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + // Хвостик: маленький радиус (4dp) только у нижнего угла со стороны + // отправителя + bottomStart = + if (message.isOutgoing) 16.dp + else (if (showTail) 4.dp else 16.dp), + bottomEnd = + if (message.isOutgoing) (if (showTail) 4.dp else 16.dp) + else 16.dp ) - } - ) { - // 🔥 Reply icon (появляется справа при свайпе влево) + } + + // 🔥 ОПТИМИЗАЦИЯ: SimpleDateFormat создаём один раз + val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + + // 🔥 Swipe-to-reply wrapper Box( modifier = - Modifier.align(Alignment.CenterEnd).padding(end = 16.dp).graphicsLayer { - alpha = swipeProgress - scaleX = swipeProgress - scaleY = swipeProgress + Modifier.fillMaxWidth().pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + // Если свайп достиг порога - активируем reply + if (swipeOffset <= -swipeThreshold) { + onSwipeToReply() + } + // Возвращаем на место + swipeOffset = 0f + }, + onDragCancel = { swipeOffset = 0f }, + onHorizontalDrag = { _, dragAmount -> + // Только свайп влево (отрицательный dragAmount) + val newOffset = swipeOffset + dragAmount + swipeOffset = newOffset.coerceIn(-maxSwipe, 0f) + } + ) } ) { - Box( - modifier = - Modifier.size(36.dp) - .clip(CircleShape) - .background( - if (swipeProgress >= 1f) PrimaryBlue - else if (isDarkTheme) Color(0xFF3A3A3A) - else Color(0xFFE0E0E0) - ), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.Reply, - contentDescription = "Reply", - tint = - if (swipeProgress >= 1f) Color.White - else if (isDarkTheme) Color.White.copy(alpha = 0.7f) - else Color(0xFF666666), - modifier = Modifier.size(20.dp) - ) - } - } - - // 🔥 TELEGRAM STYLE: Полупрозрачный синий фон для выбранных сообщений - val selectionBackgroundColor by - animateColorAsState( - targetValue = - if (isSelected) PrimaryBlue.copy(alpha = 0.15f) - else Color.Transparent, - animationSpec = tween(200), - label = "selectionBg" - ) - - // 🔥 Подсветка для highlighted сообщений (клик на reply) - голубой прозрачный - val highlightBackgroundColor by - animateColorAsState( - targetValue = - if (isHighlighted) PrimaryBlue.copy(alpha = 0.12f) - else Color.Transparent, - animationSpec = tween(300), - label = "highlightBg" - ) - - // 🔥 Комбинируем оба фона (selection имеет приоритет) - val combinedBackgroundColor = - if (isSelected) selectionBackgroundColor else highlightBackgroundColor - - Row( - modifier = - Modifier.fillMaxWidth() - // 🔥 TELEGRAM: Фоновая подсветка при выделении - на всю ширину - // экрана! - .background(combinedBackgroundColor) - // 🔥 Только vertical padding, horizontal убран чтобы выделение было - // edge-to-edge - .padding(vertical = 2.dp) - .offset { IntOffset(animatedOffset.toInt(), 0) }, - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - // 🔥 TELEGRAM STYLE: Зеленая галочка СЛЕВА (как в Telegram) - AnimatedVisibility( - visible = isSelected, - enter = - fadeIn(tween(150)) + - scaleIn( - initialScale = 0.3f, - animationSpec = spring(dampingRatio = 0.6f) - ), - exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f) - ) { + // 🔥 Reply icon (появляется справа при свайпе влево) Box( modifier = - Modifier.padding(start = 12.dp, end = 4.dp) - .size(24.dp) - .clip(CircleShape) - .background(Color(0xFF4CD964)), // Зеленый как в Telegram - contentAlignment = Alignment.Center + Modifier.align(Alignment.CenterEnd) + .padding(end = 16.dp) + .graphicsLayer { + alpha = swipeProgress + scaleX = swipeProgress + scaleY = swipeProgress + } ) { - Icon( - Icons.Default.Check, - contentDescription = "Selected", - tint = Color.White, - modifier = Modifier.size(16.dp) - ) - } - } - - // Spacer для невыбранных сообщений (чтобы пузырьки не прыгали) - AnimatedVisibility( - visible = !isSelected, - enter = fadeIn(tween(100)), - exit = fadeOut(tween(100)) - ) { - Spacer(modifier = Modifier.width(12.dp)) // Отступ слева когда нет галочки - } - - // 🔥 Spacer для выравнивания исходящих сообщений вправо - if (message.isOutgoing) { - Spacer(modifier = Modifier.weight(1f)) - } - - Box( - modifier = - Modifier - // 🔥 Добавляем горизонтальные отступы к пузырьку - .padding(end = 12.dp) - .widthIn(max = 280.dp) // 🔥 TELEGRAM: чуть уже (280dp) - .graphicsLayer { - this.alpha = selectionAlpha - this.scaleX = selectionScale - this.scaleY = selectionScale - } - .combinedClickable( - indication = null, - interactionSource = - remember { MutableInteractionSource() }, - onClick = onClick, - onLongClick = onLongClick - ) - .clip(bubbleShape) - .background(bubbleColor) - // 🔥 TELEGRAM: padding 10-12dp horizontal, 8dp vertical - .padding(horizontal = 10.dp, vertical = 8.dp) - ) { - // 🔥 TELEGRAM STYLE: текст и время на одной строке - Column { - // Reply bubble (цитата) - message.replyData?.let { reply -> - ReplyBubble( - replyData = reply, - isOutgoing = message.isOutgoing, - isDarkTheme = isDarkTheme, - onClick = { onReplyClick(reply.messageId) } - ) - Spacer(modifier = Modifier.height(4.dp)) // Меньше отступ - } - - // Текст и время в одной строке (Row) - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = - Arrangement.spacedBy(10.dp) // Увеличенный отступ до времени - ) { - // 🔥 TELEGRAM: Текст 17sp, lineHeight 22sp, letterSpacing -0.4sp - AppleEmojiText( - text = message.text, - color = textColor, - fontSize = 17.sp, - modifier = Modifier.weight(1f, fill = false) - ) - - // Время и статус справа - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.padding(bottom = 2.dp) + Box( + modifier = + Modifier.size(36.dp) + .clip(CircleShape) + .background( + if (swipeProgress >= 1f) PrimaryBlue + else if (isDarkTheme) Color(0xFF3A3A3A) + else Color(0xFFE0E0E0) + ), + contentAlignment = Alignment.Center ) { - // 🔥 TELEGRAM: Время 11sp, italic style - Text( - text = timeFormat.format(message.timestamp), - color = timeColor, - fontSize = 11.sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic - ) - if (message.isOutgoing) { - // 📁 Для Saved Messages всегда показываем READ (две галочки) - val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status - AnimatedMessageStatus( - status = displayStatus, - timeColor = timeColor, - timestamp = message.timestamp.time, - onRetry = onRetry, - onDelete = onDelete + Icon( + Icons.Default.Reply, + contentDescription = "Reply", + tint = + if (swipeProgress >= 1f) Color.White + else if (isDarkTheme) Color.White.copy(alpha = 0.7f) + else Color(0xFF666666), + modifier = Modifier.size(20.dp) ) - } } - } } - } - } - } // End of swipe Box wrapper + + // 🔥 TELEGRAM STYLE: Полупрозрачный синий фон для выбранных сообщений + val selectionBackgroundColor by + animateColorAsState( + targetValue = + if (isSelected) PrimaryBlue.copy(alpha = 0.15f) + else Color.Transparent, + animationSpec = tween(200), + label = "selectionBg" + ) + + // 🔥 Подсветка для highlighted сообщений (клик на reply) - голубой прозрачный + val highlightBackgroundColor by + animateColorAsState( + targetValue = + if (isHighlighted) PrimaryBlue.copy(alpha = 0.12f) + else Color.Transparent, + animationSpec = tween(300), + label = "highlightBg" + ) + + // 🔥 Комбинируем оба фона (selection имеет приоритет) + val combinedBackgroundColor = + if (isSelected) selectionBackgroundColor else highlightBackgroundColor + + Row( + modifier = + Modifier.fillMaxWidth() + // 🔥 TELEGRAM: Фоновая подсветка при выделении - на всю + // ширину + // экрана! + .background(combinedBackgroundColor) + // 🔥 Только vertical padding, horizontal убран чтобы + // выделение было + // edge-to-edge + .padding(vertical = 2.dp) + .offset { IntOffset(animatedOffset.toInt(), 0) }, + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + // 🔥 TELEGRAM STYLE: Зеленая галочка СЛЕВА (как в Telegram) + AnimatedVisibility( + visible = isSelected, + enter = + fadeIn(tween(150)) + + scaleIn( + initialScale = 0.3f, + animationSpec = spring(dampingRatio = 0.6f) + ), + exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f) + ) { + Box( + modifier = + Modifier.padding(start = 12.dp, end = 4.dp) + .size(24.dp) + .clip(CircleShape) + .background( + Color(0xFF4CD964) + ), // Зеленый как в Telegram + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + } + } + + // Spacer для невыбранных сообщений (чтобы пузырьки не прыгали) + AnimatedVisibility( + visible = !isSelected, + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)) + ) { + Spacer( + modifier = Modifier.width(12.dp) + ) // Отступ слева когда нет галочки + } + + // 🔥 Spacer для выравнивания исходящих сообщений вправо + if (message.isOutgoing) { + Spacer(modifier = Modifier.weight(1f)) + } + + Box( + modifier = + Modifier + // 🔥 Добавляем горизонтальные отступы к пузырьку + .padding(end = 12.dp) + .widthIn( + max = 280.dp + ) // 🔥 TELEGRAM: чуть уже (280dp) + .graphicsLayer { + this.alpha = selectionAlpha + this.scaleX = selectionScale + this.scaleY = selectionScale + } + .combinedClickable( + indication = null, + interactionSource = + remember { + MutableInteractionSource() + }, + onClick = onClick, + onLongClick = onLongClick + ) + .clip(bubbleShape) + .background(bubbleColor) + // 🔥 TELEGRAM: padding 10-12dp horizontal, 8dp + // vertical + .padding(horizontal = 10.dp, vertical = 8.dp) + ) { + // 🔥 TELEGRAM STYLE: текст и время на одной строке + Column { + // Reply bubble (цитата) + message.replyData?.let { reply -> + ReplyBubble( + replyData = reply, + isOutgoing = message.isOutgoing, + isDarkTheme = isDarkTheme, + onClick = { onReplyClick(reply.messageId) } + ) + Spacer( + modifier = Modifier.height(4.dp) + ) // Меньше отступ + } + + // Текст и время в одной строке (Row) + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = + Arrangement.spacedBy( + 10.dp + ) // Увеличенный отступ до времени + ) { + // 🔥 TELEGRAM: Текст 17sp, lineHeight 22sp, + // letterSpacing -0.4sp + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 17.sp, + modifier = Modifier.weight(1f, fill = false) + ) + + // Время и статус справа + Row( + verticalAlignment = + Alignment.CenterVertically, + horizontalArrangement = + Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(bottom = 2.dp) + ) { + // 🔥 TELEGRAM: Время 11sp, italic style + Text( + text = + timeFormat.format( + message.timestamp + ), + color = timeColor, + fontSize = 11.sp, + fontStyle = + androidx.compose.ui.text + .font.FontStyle + .Italic + ) + if (message.isOutgoing) { + // 📁 Для Saved Messages всегда + // показываем READ (две галочки) + val displayStatus = + if (isSavedMessages) + MessageStatus.READ + else message.status + AnimatedMessageStatus( + status = displayStatus, + timeColor = timeColor, + timestamp = + message.timestamp + .time, + onRetry = onRetry, + onDelete = onDelete + ) + } + } + } + } + } + } + } // End of swipe Box wrapper } /** @@ -2062,118 +2662,134 @@ private fun AnimatedMessageStatus( onRetry: () -> Unit = {}, onDelete: () -> Unit = {} ) { - // 🔥 Проверяем таймаут для SENDING статуса - val isTimedOut = - status == MessageStatus.SENDING && timestamp > 0 && !isMessageDeliveredByTime(timestamp) - val effectiveStatus = if (isTimedOut) MessageStatus.ERROR else status + // 🔥 Проверяем таймаут для SENDING статуса + val isTimedOut = + status == MessageStatus.SENDING && + timestamp > 0 && + !isMessageDeliveredByTime(timestamp) + val effectiveStatus = if (isTimedOut) MessageStatus.ERROR else status - // Цвет с анимацией - val targetColor = - when (effectiveStatus) { - MessageStatus.READ -> Color(0xFF4FC3F7) // Синий для прочитано - MessageStatus.ERROR -> Color(0xFFE53935) // Красный для ошибки - else -> timeColor - } - val animatedColor by - animateColorAsState( - targetValue = targetColor, - animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), - label = "statusColor" - ) + // Цвет с анимацией + val targetColor = + when (effectiveStatus) { + MessageStatus.READ -> Color(0xFF4FC3F7) // Синий для прочитано + MessageStatus.ERROR -> Color(0xFFE53935) // Красный для ошибки + else -> timeColor + } + val animatedColor by + animateColorAsState( + targetValue = targetColor, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + label = "statusColor" + ) - // Анимация scale для эффекта "pop" - var previousStatus by remember { mutableStateOf(effectiveStatus) } - var shouldAnimate by remember { mutableStateOf(false) } + // Анимация scale для эффекта "pop" + var previousStatus by remember { mutableStateOf(effectiveStatus) } + var shouldAnimate by remember { mutableStateOf(false) } - LaunchedEffect(effectiveStatus) { - if (previousStatus != effectiveStatus) { - shouldAnimate = true - previousStatus = effectiveStatus - } - } - - val scale by - animateFloatAsState( - targetValue = if (shouldAnimate) 1.2f else 1f, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - finishedListener = { shouldAnimate = false }, - label = "statusScale" - ) - - // 🔥 Для ошибки - показываем DropdownMenu - var showErrorMenu by remember { mutableStateOf(false) } - - Box { - // Crossfade для плавной смены иконки - Crossfade( - targetState = effectiveStatus, - animationSpec = tween(durationMillis = 200), - label = "statusIcon" - ) { currentStatus -> - Icon( - imageVector = - when (currentStatus) { - MessageStatus.SENDING -> - Icons.Default.Schedule // Часики - отправляется - MessageStatus.SENT -> - Icons.Default.Done // Одна галочка - отправлено - MessageStatus.DELIVERED -> - Icons.Default.Done // Одна галочка - доставлено - MessageStatus.READ -> - Icons.Default.DoneAll // Две галочки - прочитано - MessageStatus.ERROR -> - Icons.Default.Error // Ошибка - восклицательный знак - }, - contentDescription = null, - tint = animatedColor, - modifier = - Modifier.size(16.dp) - .scale(scale) - .then( - if (currentStatus == MessageStatus.ERROR) { - Modifier.clickable { showErrorMenu = true } - } else Modifier - ) - ) + LaunchedEffect(effectiveStatus) { + if (previousStatus != effectiveStatus) { + shouldAnimate = true + previousStatus = effectiveStatus + } } - // 🔥 Меню ошибки (как в архиве) - DropdownMenu(expanded = showErrorMenu, onDismissRequest = { showErrorMenu = false }) { - DropdownMenuItem( - text = { Text("Retry") }, - onClick = { - showErrorMenu = false - onRetry() - }, - leadingIcon = { + val scale by + animateFloatAsState( + targetValue = if (shouldAnimate) 1.2f else 1f, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + finishedListener = { shouldAnimate = false }, + label = "statusScale" + ) + + // 🔥 Для ошибки - показываем DropdownMenu + var showErrorMenu by remember { mutableStateOf(false) } + + Box { + // Crossfade для плавной смены иконки + Crossfade( + targetState = effectiveStatus, + animationSpec = tween(durationMillis = 200), + label = "statusIcon" + ) { currentStatus -> + // 🔥 Фиксированный размер 14sp для всех иконок статуса + val iconSize = with(LocalDensity.current) { 14.sp.toDp() } + Icon( - Icons.Default.Refresh, + imageVector = + when (currentStatus) { + MessageStatus.SENDING -> + Icons.Default + .Schedule // Часики - отправляется + MessageStatus.SENT -> + Icons.Default + .Done // Одна галочка - отправлено + MessageStatus.DELIVERED -> + Icons.Default + .Done // Одна галочка - доставлено + MessageStatus.READ -> + Icons.Default + .DoneAll // Две галочки - прочитано + MessageStatus.ERROR -> + Icons.Default + .Error // Ошибка - восклицательный + // знак + }, contentDescription = null, - modifier = Modifier.size(18.dp) + tint = animatedColor, + modifier = + Modifier.size(iconSize) + .scale(scale) + .then( + if (currentStatus == MessageStatus.ERROR) { + Modifier.clickable { + showErrorMenu = true + } + } else Modifier + ) ) - } - ) - DropdownMenuItem( - text = { Text("Delete", color = Color(0xFFE53935)) }, - onClick = { - showErrorMenu = false - onDelete() - }, - leadingIcon = { - Icon( - Icons.Default.Delete, - contentDescription = null, - tint = Color(0xFFE53935), - modifier = Modifier.size(18.dp) + } + + // 🔥 Меню ошибки (как в архиве) + DropdownMenu( + expanded = showErrorMenu, + onDismissRequest = { showErrorMenu = false } + ) { + DropdownMenuItem( + text = { Text("Retry") }, + onClick = { + showErrorMenu = false + onRetry() + }, + leadingIcon = { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } ) - } - ) + DropdownMenuItem( + text = { Text("Delete", color = Color(0xFFE53935)) }, + onClick = { + showErrorMenu = false + onDelete() + }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = Color(0xFFE53935), + modifier = Modifier.size(18.dp) + ) + } + ) + } } - } } /** 🔥 Reply bubble (цитата) внутри сообщения Стиль: вертикальная линия слева + имя + текст */ @@ -2184,110 +2800,114 @@ private fun ReplyBubble( isDarkTheme: Boolean, onClick: () -> Unit = {} // 🔥 Клик на цитату ) { - // НАШИ ОРИГИНАЛЬНЫЕ ЦВЕТА - val backgroundColor = - if (isOutgoing) { - Color.Black.copy(alpha = 0.15f) - } else { - Color.Black.copy(alpha = 0.08f) - } + // НАШИ ОРИГИНАЛЬНЫЕ ЦВЕТА + val backgroundColor = + if (isOutgoing) { + Color.Black.copy(alpha = 0.15f) + } else { + Color.Black.copy(alpha = 0.08f) + } - val borderColor = - if (isOutgoing) { - Color.White - } else { - PrimaryBlue - } + val borderColor = + if (isOutgoing) { + Color.White + } else { + PrimaryBlue + } - val nameColor = - if (isOutgoing) { - Color.White - } else { - PrimaryBlue - } + val nameColor = + if (isOutgoing) { + Color.White + } else { + PrimaryBlue + } - val replyTextColor = - if (isOutgoing) { - Color.White.copy(alpha = 0.85f) - } else { - if (isDarkTheme) Color.White else Color.Black - } + val replyTextColor = + if (isOutgoing) { + Color.White.copy(alpha = 0.85f) + } else { + if (isDarkTheme) Color.White else Color.Black + } - Row( - modifier = - Modifier.wrapContentWidth() - .height(IntrinsicSize.Min) - .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = onClick) // 🔥 Клик на цитату - .background(backgroundColor) - ) { - // 🔥 TELEGRAM: Вертикальная линия слева 3dp - Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor)) - - // Контент - Column( + Row( modifier = - Modifier - // 🔥 TELEGRAM: padding как в дизайне - .padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp) - .widthIn(max = 220.dp) + Modifier.fillMaxWidth() + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) // 🔥 Клик на цитату + .background(backgroundColor) ) { - // 🔥 TELEGRAM: Имя 14sp, Medium weight - Text( - text = replyData.senderName, - color = nameColor, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + // 🔥 TELEGRAM: Вертикальная линия слева 3dp + Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor)) - // 🔥 TELEGRAM: Текст цитаты 14sp, Regular - Text( - text = replyData.text.ifEmpty { "..." }, - color = replyTextColor, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + // Контент + Column( + modifier = + Modifier.fillMaxWidth() + // 🔥 TELEGRAM: padding как в дизайне + .padding( + start = 8.dp, + end = 10.dp, + top = 4.dp, + bottom = 4.dp + ) + ) { + // 🔥 TELEGRAM: Имя 14sp, Medium weight + Text( + text = replyData.senderName, + color = nameColor, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // 🔥 TELEGRAM: Текст цитаты 14sp, Regular + Text( + text = replyData.text.ifEmpty { "..." }, + color = replyTextColor, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } - } } /** 🚀 Разделитель даты с fade-in анимацией */ @Composable private fun DateHeader(dateText: String, secondaryTextColor: Color) { - // Fade-in анимация - var isVisible by remember { mutableStateOf(false) } - val alpha by - animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = tween(durationMillis = 250, easing = TelegramEasing), - label = "dateAlpha" - ) + // Fade-in анимация + var isVisible by remember { mutableStateOf(false) } + val alpha by + animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), + label = "dateAlpha" + ) - LaunchedEffect(dateText) { isVisible = true } + LaunchedEffect(dateText) { isVisible = true } - Row( - modifier = - Modifier.fillMaxWidth().padding(vertical = 12.dp).graphicsLayer { - this.alpha = alpha - }, - horizontalArrangement = Arrangement.Center - ) { - Text( - text = dateText, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = secondaryTextColor, + Row( modifier = - Modifier.background( - color = secondaryTextColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(12.dp) - ) - .padding(horizontal = 12.dp, vertical = 4.dp) - ) - } + Modifier.fillMaxWidth().padding(vertical = 12.dp).graphicsLayer { + this.alpha = alpha + }, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = dateText, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = secondaryTextColor, + modifier = + Modifier.background( + color = secondaryTextColor.copy(alpha = 0.1f), + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) + } } /** @@ -2328,577 +2948,739 @@ private fun MessageInputBar( displayReplyMessages: List = emptyList(), onReplyClick: (String) -> Unit = {} ) { - // 🔥 ОПТИМИЗАЦИЯ: Убрано логирование чтобы не засорять logcat + // 🔥 ОПТИМИЗАЦИЯ: Убрано логирование чтобы не засорять logcat - val hasReply = replyMessages.isNotEmpty() - val keyboardController = LocalSoftwareKeyboardController.current - val focusManager = LocalFocusManager.current - val interactionSource = remember { MutableInteractionSource() } - val scope = rememberCoroutineScope() + val hasReply = replyMessages.isNotEmpty() + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val interactionSource = remember { MutableInteractionSource() } + val scope = rememberCoroutineScope() - // Получаем context и view для гарантированного закрытия клавиатуры - val context = LocalContext.current - val view = LocalView.current - val density = LocalDensity.current + // Получаем context и view для гарантированного закрытия клавиатуры + val context = LocalContext.current + val view = LocalView.current + val density = LocalDensity.current - // 🔥 Ссылка на EditText для программного фокуса - var editTextView by remember { - mutableStateOf(null) - } - - // 🔥 Автофокус при открытии reply панели - LaunchedEffect(hasReply, editTextView) { - if (hasReply) { - // Даём время на создание view если ещё null - kotlinx.coroutines.delay(50) - editTextView?.let { editText -> - // 🔥 НЕ открываем клавиатуру если emoji уже открыт - if (!showEmojiPicker) { - editText.requestFocus() - // Открываем клавиатуру - val imm = - context.getSystemService(Context.INPUT_METHOD_SERVICE) as - InputMethodManager - imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) - } else {} - } - } - } - - // 🔥 ОПТИМИЗАЦИЯ: НЕ читаем imeHeight напрямую - это вызывает рекомпозицию! - val imeInsets = WindowInsets.ime - - // 🔥 Флаг "клавиатура видна" - обновляется через snapshotFlow, НЕ вызывает рекомпозицию - var isKeyboardVisible by remember { mutableStateOf(false) } - var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) } - - // � Защита от слишком частого переключения клавиатуры (300ms cooldown) - var lastToggleTime by remember { mutableLongStateOf(0L) } - val toggleCooldownMs = 500L - - // 🚫 Флаг "клавиатура анимируется" - блокирует переключение пока высота не стабилизируется - var isKeyboardAnimating by remember { mutableStateOf(false) } - var lastKeyboardHeightChange by remember { mutableLongStateOf(0L) } - val keyboardAnimationStabilizeMs = 250L // Ждем стабилизации 250ms - // �🔥 Обновляем coordinator через snapshotFlow (БЕЗ рекомпозиции!) - LaunchedEffect(Unit) { - snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { - currentImeHeight -> - // 🚫 Отслеживаем изменения высоты - если меняется, клава анимируется - val now = System.currentTimeMillis() - val heightChanged = - kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f - if (heightChanged && currentImeHeight.value > 0) { - lastKeyboardHeightChange = now - isKeyboardAnimating = true - } - // Если высота не менялась > 250ms - анимация завершена - if (now - lastKeyboardHeightChange > keyboardAnimationStabilizeMs && isKeyboardAnimating - ) { - isKeyboardAnimating = false - } - - // Обновляем флаг видимости (это НЕ вызывает рекомпозицию напрямую) - isKeyboardVisible = currentImeHeight > 50.dp - - // Обновляем coordinator - coordinator.updateKeyboardHeight(currentImeHeight) - if (currentImeHeight > 100.dp) { - coordinator.syncHeights() - lastStableKeyboardHeight = currentImeHeight - } - } - } - - // 🔥 Запоминаем высоту клавиатуры когда она открыта (Telegram-style with SharedPreferences) - LaunchedEffect(Unit) { - // Загружаем сохранённую высоту при старте - com.rosetta.messenger.ui.components.KeyboardHeightProvider.getSavedKeyboardHeight(context) - } - - // 🔥 Сохраняем высоту ТОЛЬКО когда клавиатура стабильна - LaunchedEffect(isKeyboardVisible, showEmojiPicker) { - // Если клавиатура стала видимой и emoji закрыт - if (isKeyboardVisible && !showEmojiPicker) { - // Ждем стабилизации - kotlinx.coroutines.delay(350) // Анимация клавиатуры ~300ms - - // Сохраняем только если всё еще видна и emoji закрыт - if (isKeyboardVisible && !showEmojiPicker && lastStableKeyboardHeight > 300.dp) { - val heightPx = with(density) { lastStableKeyboardHeight.toPx().toInt() } - com.rosetta.messenger.ui.components.KeyboardHeightProvider.saveKeyboardHeight( - context, - heightPx - ) - } - } - } - - // Состояние отправки - можно отправить если есть текст ИЛИ есть reply - val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply } - - // 🔥 Флаг отправки - предотвращает исчезновение кнопки Send во время отправки reply - var isSending by remember { mutableStateOf(false) } - - // 🔥 УДАЛЕНО: автоматическое закрытие emoji при открытии клавиатуры - // Теперь это контролируется только через toggleEmojiPicker() - - // 🔥 Закрываем клавиатуру когда пользователь заблокирован - LaunchedEffect(isBlocked) { - if (isBlocked) { - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus(force = true) - } - } - - // Функция для гарантированного закрытия клавиатуры через InputMethodManager - fun hideKeyboardCompletely() { - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus(force = true) - } - - // 🔥 Функция переключения emoji picker с Telegram-style transitions - fun toggleEmojiPicker() { - // 🚫 Защита от слишком частого переключения (только cooldown, убираем строгие блокировки) - val currentTime = System.currentTimeMillis() - val timeSinceLastToggle = currentTime - lastToggleTime - - if (timeSinceLastToggle < toggleCooldownMs) { - return + // 🔥 Ссылка на EditText для программного фокуса + var editTextView by remember { + mutableStateOf(null) } - lastToggleTime = currentTime - - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - - coordinator.logState() - - // 🔥 ИСПОЛЬЗУЕМ coordinator.isEmojiVisible вместо showEmojiPicker для более точного - // состояния - if (coordinator.isEmojiVisible) { - // ========== EMOJI → KEYBOARD ========== - coordinator.requestShowKeyboard( - showKeyboard = { + // 🔥 Автофокус при открытии reply панели + LaunchedEffect(hasReply, editTextView) { + if (hasReply) { + // Даём время на создание view если ещё null + kotlinx.coroutines.delay(50) editTextView?.let { editText -> - editText.requestFocus() - imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED) - } - }, - hideEmoji = { onToggleEmojiPicker(false) } - ) - } else { - // ========== KEYBOARD → EMOJI ========== - coordinator.requestShowEmoji( - hideKeyboard = { imm.hideSoftInputFromWindow(view.windowToken, 0) }, - showEmoji = { onToggleEmojiPicker(true) } - ) - } - } - - // Функция отправки - НЕ закрывает клавиатуру (UX правило #6) - fun handleSend() { - // Можно отправить если есть текст ИЛИ есть reply (как в React Native) - if (value.isNotBlank() || hasReply) { - // 🔥 Устанавливаем флаг отправки чтобы кнопка не исчезла во время отправки - isSending = true - onSend() - // Сбрасываем флаг через небольшую задержку (после того как reply очистится) - scope.launch { - kotlinx.coroutines.delay(150) // Даём время на анимацию - isSending = false - } - // Очищаем инпут, но клавиатура остаётся открытой - } - } - - Column( - modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false } - // imePadding уже применён к родительскому контейнеру - ) { - // Если пользователь заблокирован - показываем BlockedChatFooter (плоский как инпут) - if (isBlocked) { - // BLOCKED CHAT FOOTER - плоский стиль - Column(modifier = Modifier.fillMaxWidth().background(backgroundColor)) { - // Border сверху - Box( - modifier = - Modifier.fillMaxWidth() - .height(0.5.dp) - .background( - if (isDarkTheme) Color.White.copy(alpha = 0.1f) - else Color.Black.copy(alpha = 0.08f) + // 🔥 НЕ открываем клавиатуру если emoji уже открыт + if (!showEmojiPicker) { + editText.requestFocus() + // Открываем клавиатуру + val imm = + context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as + InputMethodManager + imm.showSoftInput( + editText, + InputMethodManager.SHOW_IMPLICIT ) - ) - - // BLOCKED CHAT FOOTER - плоский стиль - // 🔥 Высота должна совпадать с MessageInputBar и Selection Action Bar - Row( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .padding(bottom = 16.dp) - .navigationBarsPadding(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon( - Icons.Default.Block, - contentDescription = null, - tint = Color(0xFFFF6B6B), - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "You need to unblock user to send messages.", - fontSize = 14.sp, - color = secondaryTextColor, - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) + } else {} + } } - } - } else { - // 🔥 TELEGRAM STYLE: фон как у чата, верхний border - Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) { - // Верхний border (как в архиве) - Box( - modifier = - Modifier.fillMaxWidth() - .height(0.5.dp) - .background( - if (isDarkTheme) Color.White.copy(alpha = 0.1f) - else Color.Black.copy(alpha = 0.08f) - ) + } + + // 🔥 ОПТИМИЗАЦИЯ: НЕ читаем imeHeight напрямую - это вызывает рекомпозицию! + val imeInsets = WindowInsets.ime + + // 🔥 Флаг "клавиатура видна" - обновляется через snapshotFlow, НЕ вызывает рекомпозицию + var isKeyboardVisible by remember { mutableStateOf(false) } + var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) } + + // � Защита от слишком частого переключения клавиатуры (300ms cooldown) + var lastToggleTime by remember { mutableLongStateOf(0L) } + val toggleCooldownMs = 500L + + // 🚫 Флаг "клавиатура анимируется" - блокирует переключение пока высота не стабилизируется + var isKeyboardAnimating by remember { mutableStateOf(false) } + var lastKeyboardHeightChange by remember { mutableLongStateOf(0L) } + val keyboardAnimationStabilizeMs = 250L // Ждем стабилизации 250ms + // �🔥 Обновляем coordinator через snapshotFlow (БЕЗ рекомпозиции!) + LaunchedEffect(Unit) { + snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { + currentImeHeight -> + // 🚫 Отслеживаем изменения высоты - если меняется, клава анимируется + val now = System.currentTimeMillis() + val heightChanged = + kotlin.math.abs( + (currentImeHeight - lastStableKeyboardHeight).value + ) > 5f + if (heightChanged && currentImeHeight.value > 0) { + lastKeyboardHeightChange = now + isKeyboardAnimating = true + } + // Если высота не менялась > 250ms - анимация завершена + if (now - lastKeyboardHeightChange > keyboardAnimationStabilizeMs && + isKeyboardAnimating + ) { + isKeyboardAnimating = false + } + + // Обновляем флаг видимости (это НЕ вызывает рекомпозицию напрямую) + isKeyboardVisible = currentImeHeight > 50.dp + + // Обновляем coordinator + coordinator.updateKeyboardHeight(currentImeHeight) + if (currentImeHeight > 100.dp) { + coordinator.syncHeights() + lastStableKeyboardHeight = currentImeHeight + } + } + } + + // 🔥 Запоминаем высоту клавиатуры когда она открыта (Telegram-style with SharedPreferences) + LaunchedEffect(Unit) { + // Загружаем сохранённую высоту при старте + com.rosetta.messenger.ui.components.KeyboardHeightProvider.getSavedKeyboardHeight( + context ) + } - // 🔥 Когда emoji Box виден ИЛИ клавиатура открыта - НЕ добавляем navigation bar - // padding - val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible + // 🔥 Сохраняем высоту ТОЛЬКО когда клавиатура стабильна + LaunchedEffect(isKeyboardVisible, showEmojiPicker) { + // Если клавиатура стала видимой и emoji закрыт + if (isKeyboardVisible && !showEmojiPicker) { + // Ждем стабилизации + kotlinx.coroutines.delay(350) // Анимация клавиатуры ~300ms - Column( - modifier = - Modifier.fillMaxWidth() - .background( - color = backgroundColor // Тот же цвет что и фон - // чата + // Сохраняем только если всё еще видна и emoji закрыт + if (isKeyboardVisible && + !showEmojiPicker && + lastStableKeyboardHeight > 300.dp + ) { + val heightPx = + with(density) { lastStableKeyboardHeight.toPx().toInt() } + com.rosetta.messenger.ui.components.KeyboardHeightProvider + .saveKeyboardHeight(context, heightPx) + } + } + } + + // Состояние отправки - можно отправить если есть текст ИЛИ есть reply + val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply } + + // 🔥 Флаг отправки - предотвращает исчезновение кнопки Send во время отправки reply + var isSending by remember { mutableStateOf(false) } + + // 🔥 УДАЛЕНО: автоматическое закрытие emoji при открытии клавиатуры + // Теперь это контролируется только через toggleEmojiPicker() + + // 🔥 Закрываем клавиатуру когда пользователь заблокирован + LaunchedEffect(isBlocked) { + if (isBlocked) { + val imm = + context.getSystemService(Context.INPUT_METHOD_SERVICE) as + InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus(force = true) + } + } + + // Функция для гарантированного закрытия клавиатуры через InputMethodManager + fun hideKeyboardCompletely() { + val imm = + context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus(force = true) + } + + // 🔥 Функция переключения emoji picker с Telegram-style transitions + fun toggleEmojiPicker() { + // 🚫 Защита от слишком частого переключения (только cooldown, убираем строгие + // блокировки) + val currentTime = System.currentTimeMillis() + val timeSinceLastToggle = currentTime - lastToggleTime + + if (timeSinceLastToggle < toggleCooldownMs) { + return + } + + lastToggleTime = currentTime + + val imm = + context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + + coordinator.logState() + + // 🔥 ИСПОЛЬЗУЕМ coordinator.isEmojiVisible вместо showEmojiPicker для более точного + // состояния + if (coordinator.isEmojiVisible) { + // ========== EMOJI → KEYBOARD ========== + coordinator.requestShowKeyboard( + showKeyboard = { + editTextView?.let { editText -> + editText.requestFocus() + imm.showSoftInput( + editText, + InputMethodManager.SHOW_FORCED ) - .padding( - bottom = - if (isKeyboardVisible || - coordinator - .isEmojiBoxVisible - ) - 0.dp - else 16.dp - ) - .then( - if (shouldAddNavBarPadding) - Modifier.navigationBarsPadding() - else Modifier - ) + } + }, + hideEmoji = { onToggleEmojiPicker(false) } + ) + } else { + // ========== KEYBOARD → EMOJI ========== + coordinator.requestShowEmoji( + hideKeyboard = { imm.hideSoftInputFromWindow(view.windowToken, 0) }, + showEmoji = { onToggleEmojiPicker(true) } + ) + } + } + + // Функция отправки - НЕ закрывает клавиатуру (UX правило #6) + fun handleSend() { + // Можно отправить если есть текст ИЛИ есть reply (как в React Native) + if (value.isNotBlank() || hasReply) { + // 🔥 Устанавливаем флаг отправки чтобы кнопка не исчезла во время отправки + isSending = true + onSend() + // Сбрасываем флаг через небольшую задержку (после того как reply очистится) + scope.launch { + kotlinx.coroutines.delay(150) // Даём время на анимацию + isSending = false + } + // Очищаем инпут, но клавиатура остаётся открытой + } + } + + Column( + modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false } + // imePadding уже применён к родительскому контейнеру ) { - // REPLY PANEL - плавная анимация появления/исчезновения - AnimatedVisibility( - visible = hasReply, - enter = - fadeIn( - animationSpec = - tween( - durationMillis = 200, - easing = FastOutSlowInEasing - ) - ) + - expandVertically( - animationSpec = - spring( - dampingRatio = - Spring.DampingRatioMediumBouncy, - stiffness = - Spring.StiffnessMedium - ), - expandFrom = Alignment.Bottom - ), - exit = - fadeOut( - animationSpec = - tween( - durationMillis = 150, - easing = FastOutLinearInEasing - ) - ) + - shrinkVertically( - animationSpec = - tween( - durationMillis = 150, - easing = FastOutLinearInEasing - ), - shrinkTowards = Alignment.Bottom - ) - ) { - Row( - modifier = - Modifier.fillMaxWidth() - .clickable { - // 🔥 При клике на reply preview - скроллим к - // первому сообщению - if (displayReplyMessages.isNotEmpty()) { - onReplyClick( - displayReplyMessages.first() - .messageId + // Если пользователь заблокирован - показываем BlockedChatFooter (плоский как инпут) + if (isBlocked) { + // BLOCKED CHAT FOOTER - плоский стиль + Column(modifier = Modifier.fillMaxWidth().background(backgroundColor)) { + // Border сверху + Box( + modifier = + Modifier.fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) + Color.White.copy( + alpha = 0.1f + ) + else Color.Black.copy(alpha = 0.08f) ) - } - } - .background( - backgroundColor - ) // Тот же цвет что и фон чата - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = - Modifier.width(3.dp) - .height(32.dp) - .background( - PrimaryBlue, - RoundedCornerShape(1.5.dp) - ) - ) - Spacer(modifier = Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = - if (isForwardMode) - "Forward message${if (displayReplyMessages.size > 1) "s" else ""}" - else - "Reply to ${if (displayReplyMessages.size == 1 && !displayReplyMessages.first().isOutgoing) chatTitle else "You"}", - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - color = PrimaryBlue, - maxLines = 1 ) - Spacer(modifier = Modifier.height(2.dp)) - // Превью ответа - if (displayReplyMessages.isNotEmpty()) { - Text( - text = - if (displayReplyMessages.size == 1) { - val msg = displayReplyMessages.first() - val shortText = msg.text.take(40) - if (shortText.length < msg.text.length) - "$shortText..." - else shortText - } else "${displayReplyMessages.size} messages", - fontSize = 13.sp, - color = - if (isDarkTheme) Color.White.copy(alpha = 0.6f) - else Color.Black.copy(alpha = 0.5f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + + // BLOCKED CHAT FOOTER - плоский стиль + // 🔥 Высота должна совпадать с MessageInputBar и Selection Action + // Bar + Row( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = 12.dp, + vertical = 8.dp + ) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + Icons.Default.Block, + contentDescription = null, + tint = Color(0xFFFF6B6B), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "You need to unblock user to send messages.", + fontSize = 14.sp, + color = secondaryTextColor, + textAlign = + androidx.compose.ui.text.style.TextAlign + .Center + ) } - } - // 🔥 Box с clickable вместо IconButton - убираем задержку ripple - Box( - modifier = - Modifier.size(32.dp) - .clickable( - interactionSource = - remember { - MutableInteractionSource() - }, - indication = null, // Убираем ripple - // индикацию для - // мгновенного клика - onClick = onCloseReply - ), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.Close, - contentDescription = "Cancel", - tint = - if (isDarkTheme) Color.White.copy(alpha = 0.5f) - else Color.Black.copy(alpha = 0.4f), - modifier = Modifier.size(18.dp) - ) - } } - } - - // INPUT ROW - Paperclip → TextField → Emoji → Send/Mic (ПЛОСКИЙ ДИЗАЙН) - Row( - modifier = - Modifier.fillMaxWidth() - .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom - ) { - // PAPERCLIP BUTTON (слева) - IconButton( - onClick = { /* TODO: Attach file/image */}, - modifier = Modifier.size(40.dp) - ) { - Icon( - Icons.Default.AttachFile, - contentDescription = "Attach", - tint = - if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) - else Color(0xFF8E8E93).copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - } - - Spacer(modifier = Modifier.width(4.dp)) - - // TEXT INPUT - ПЛОСКИЙ (тот же цвет что и фон чата) - Box( - modifier = - Modifier.weight(1f) - .heightIn( - min = 40.dp, - max = 150.dp - ) // 🔥 Ограничиваем максимум, но даём расти - .background( - color = backgroundColor // Тот же цвет что и - // фон чата + } else { + // 🔥 TELEGRAM STYLE: фон как у чата, верхний border + Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) { + // Верхний border (как в архиве) + Box( + modifier = + Modifier.fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) + Color.White.copy( + alpha = 0.1f + ) + else Color.Black.copy(alpha = 0.08f) ) - .padding(horizontal = 12.dp, vertical = 8.dp), - contentAlignment = - Alignment.TopStart // 🔥 TopStart чтобы текст не обрезался - ) { - AppleEmojiTextField( - value = value, - onValueChange = { newValue -> onValueChange(newValue) }, - textColor = textColor, - textSize = 16f, - hint = "Type message...", - hintColor = - if (isDarkTheme) Color(0xFF8E8E93) - else Color(0xFF8E8E93), - modifier = Modifier.fillMaxWidth(), - requestFocus = hasReply, - onViewCreated = { view -> - // 🔥 Сохраняем ссылку на EditText для программного открытия - // клавиатуры - editTextView = view - }, - onFocusChanged = { hasFocus -> - - // Если TextField получил фокус И emoji открыт → закрываем - // emoji - if (hasFocus && showEmojiPicker) { - onToggleEmojiPicker(false) - } else if (hasFocus && !showEmojiPicker - ) {} else if (!hasFocus) {} - } - ) - } - - Spacer(modifier = Modifier.width(6.dp)) - - // EMOJI BUTTON (между input и send) - IconButton( - onClick = { toggleEmojiPicker() }, - modifier = Modifier.size(40.dp) - ) { - Icon( - if (showEmojiPicker) Icons.Default.Keyboard - else Icons.Default.SentimentSatisfiedAlt, - contentDescription = "Emoji", - tint = - if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) - else Color(0xFF8E8E93).copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - } - - Spacer(modifier = Modifier.width(2.dp)) - - // SEND BUTTON (всегда справа) - с анимацией - // 🔥 Кнопка видна если: есть текст ИЛИ есть reply ИЛИ идёт отправка - AnimatedVisibility( - visible = canSend || isSending, - enter = scaleIn(tween(150)) + fadeIn(tween(150)), - exit = scaleOut(tween(100)) + fadeOut(tween(100)) - ) { - IconButton( - onClick = { handleSend() }, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = TelegramSendIcon, - contentDescription = "Send", - tint = PrimaryBlue, - modifier = Modifier.size(24.dp) ) - } - } - } - } // Закрытие внутренней Column с padding - } // Закрытие внешней Column с border - } // End of else (not blocked) - // 🔥 EMOJI PICKER с плавными Telegram-style анимациями - if (!isBlocked) { - AnimatedKeyboardTransition( - coordinator = coordinator, - showEmojiPicker = showEmojiPicker - ) { - OptimizedEmojiPicker( - isVisible = true, // Видимость контролирует AnimatedKeyboardTransition - isDarkTheme = isDarkTheme, - onEmojiSelected = { emoji -> onValueChange(value + emoji) }, - onClose = { - // Используем coordinator для плавного перехода - toggleEmojiPicker() - }, - modifier = Modifier.fillMaxWidth() - ) - } - } // End of if (!isBlocked) for emoji picker - } + // 🔥 Когда emoji Box виден ИЛИ клавиатура открыта - НЕ добавляем + // navigation bar + // padding + val shouldAddNavBarPadding = + !isKeyboardVisible && !coordinator.isEmojiBoxVisible + + Column( + modifier = + Modifier.fillMaxWidth() + .background( + color = backgroundColor // Тот же + // цвет что + // и фон + // чата + ) + .padding( + bottom = + if (isKeyboardVisible || + coordinator + .isEmojiBoxVisible + ) + 0.dp + else 16.dp + ) + .then( + if (shouldAddNavBarPadding) + Modifier.navigationBarsPadding() + else Modifier + ) + ) { + // REPLY PANEL - плавная анимация появления/исчезновения + AnimatedVisibility( + visible = hasReply, + enter = + fadeIn( + animationSpec = + tween( + durationMillis = + 200, + easing = + FastOutSlowInEasing + ) + ) + + expandVertically( + animationSpec = + spring( + dampingRatio = + Spring.DampingRatioMediumBouncy, + stiffness = + Spring.StiffnessMedium + ), + expandFrom = + Alignment.Bottom + ), + exit = + fadeOut( + animationSpec = + tween( + durationMillis = + 150, + easing = + FastOutLinearInEasing + ) + ) + + shrinkVertically( + animationSpec = + tween( + durationMillis = + 150, + easing = + FastOutLinearInEasing + ), + shrinkTowards = + Alignment.Bottom + ) + ) { + Row( + modifier = + Modifier.fillMaxWidth() + .clickable { + // 🔥 При клике на + // reply preview - + // скроллим к + // первому сообщению + if (displayReplyMessages + .isNotEmpty() + ) { + onReplyClick( + displayReplyMessages + .first() + .messageId + ) + } + } + .background( + backgroundColor + ) // Тот же цвет что и фон + // чата + .padding( + horizontal = 12.dp, + vertical = 8.dp + ), + verticalAlignment = + Alignment.CenterVertically + ) { + Box( + modifier = + Modifier.width(3.dp) + .height(32.dp) + .background( + PrimaryBlue, + RoundedCornerShape( + 1.5.dp + ) + ) + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = + if (isForwardMode) + "Forward message${if (displayReplyMessages.size > 1) "s" else ""}" + else + "Reply to ${if (displayReplyMessages.size == 1 && !displayReplyMessages.first().isOutgoing) chatTitle else "You"}", + fontSize = 13.sp, + fontWeight = + FontWeight.SemiBold, + color = PrimaryBlue, + maxLines = 1 + ) + Spacer( + modifier = + Modifier.height( + 2.dp + ) + ) + // Превью ответа + if (displayReplyMessages + .isNotEmpty() + ) { + Text( + text = + if (displayReplyMessages + .size == + 1 + ) { + val msg = + displayReplyMessages + .first() + val shortText = + msg.text + .take( + 40 + ) + if (shortText + .length < + msg.text + .length + ) + "$shortText..." + else + shortText + } else + "${displayReplyMessages.size} messages", + fontSize = 13.sp, + color = + if (isDarkTheme + ) + Color.White + .copy( + alpha = + 0.6f + ) + else + Color.Black + .copy( + alpha = + 0.5f + ), + maxLines = 1, + overflow = + TextOverflow + .Ellipsis + ) + } + } + // 🔥 Box с clickable вместо IconButton - + // убираем задержку ripple + Box( + modifier = + Modifier.size(32.dp) + .clickable( + interactionSource = + remember { + MutableInteractionSource() + }, + indication = + null, // Убираем ripple + // индикацию + // для + // мгновенного клика + onClick = + onCloseReply + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Close, + contentDescription = + "Cancel", + tint = + if (isDarkTheme) + Color.White + .copy( + alpha = + 0.5f + ) + else + Color.Black + .copy( + alpha = + 0.4f + ), + modifier = + Modifier.size(18.dp) + ) + } + } + } + + // INPUT ROW - Paperclip → TextField → Emoji → Send/Mic + // (ПЛОСКИЙ ДИЗАЙН) + Row( + modifier = + Modifier.fillMaxWidth() + .heightIn(min = 48.dp) + .padding( + horizontal = 12.dp, + vertical = 8.dp + ), + verticalAlignment = Alignment.Bottom + ) { + // PAPERCLIP BUTTON (слева) + IconButton( + onClick = { /* TODO: Attach file/image */}, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Default.AttachFile, + contentDescription = "Attach", + tint = + if (isDarkTheme) + Color(0xFF8E8E93) + .copy( + alpha = + 0.6f + ) + else + Color(0xFF8E8E93) + .copy( + alpha = + 0.6f + ), + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + // TEXT INPUT - ПЛОСКИЙ (тот же цвет что и фон чата) + Box( + modifier = + Modifier.weight(1f) + .heightIn( + min = 40.dp, + max = 150.dp + ) // 🔥 Ограничиваем + // максимум, но даём расти + .background( + color = + backgroundColor // Тот же цвет что и + // фон чата + ) + .padding( + horizontal = 12.dp, + vertical = 8.dp + ), + contentAlignment = + Alignment.TopStart // 🔥 TopStart + // чтобы текст не + // обрезался + ) { + AppleEmojiTextField( + value = value, + onValueChange = { newValue -> + onValueChange(newValue) + }, + textColor = textColor, + textSize = 16f, + hint = "Type message...", + hintColor = + if (isDarkTheme) + Color(0xFF8E8E93) + else Color(0xFF8E8E93), + modifier = Modifier.fillMaxWidth(), + requestFocus = hasReply, + onViewCreated = { view -> + // 🔥 Сохраняем ссылку на + // EditText для программного + // открытия + // клавиатуры + editTextView = view + }, + onFocusChanged = { hasFocus -> + + // Если TextField получил + // фокус И emoji открыт → + // закрываем + // emoji + if (hasFocus && + showEmojiPicker + ) { + onToggleEmojiPicker( + false + ) + } else if (hasFocus && + !showEmojiPicker + ) {} else if (!hasFocus) {} + } + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + // EMOJI BUTTON (между input и send) + IconButton( + onClick = { toggleEmojiPicker() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + if (showEmojiPicker) + Icons.Default.Keyboard + else + Icons.Default + .SentimentSatisfiedAlt, + contentDescription = "Emoji", + tint = + if (isDarkTheme) + Color(0xFF8E8E93) + .copy( + alpha = + 0.6f + ) + else + Color(0xFF8E8E93) + .copy( + alpha = + 0.6f + ), + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(2.dp)) + + // SEND BUTTON (всегда справа) - с анимацией + // 🔥 Кнопка видна если: есть текст ИЛИ есть reply + // ИЛИ идёт отправка + AnimatedVisibility( + visible = canSend || isSending, + enter = + scaleIn(tween(150)) + + fadeIn(tween(150)), + exit = + scaleOut(tween(100)) + + fadeOut(tween(100)) + ) { + IconButton( + onClick = { handleSend() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = + TelegramSendIcon, + contentDescription = "Send", + tint = PrimaryBlue, + modifier = + Modifier.size(24.dp) + ) + } + } + } + } // Закрытие внутренней Column с padding + } // Закрытие внешней Column с border + } // End of else (not blocked) + + // 🔥 EMOJI PICKER с плавными Telegram-style анимациями + if (!isBlocked) { + AnimatedKeyboardTransition( + coordinator = coordinator, + showEmojiPicker = showEmojiPicker + ) { + OptimizedEmojiPicker( + isVisible = true, // Видимость контролирует + // AnimatedKeyboardTransition + isDarkTheme = isDarkTheme, + onEmojiSelected = { emoji -> onValueChange(value + emoji) }, + onClose = { + // Используем coordinator для плавного перехода + toggleEmojiPicker() + }, + modifier = Modifier.fillMaxWidth() + ) + } + } // End of if (!isBlocked) for emoji picker + } } /** 💬 Typing Indicator с анимацией точек (как в Telegram) */ @Composable fun TypingIndicator(isDarkTheme: Boolean) { - val infiniteTransition = rememberInfiniteTransition(label = "typing") - val typingColor = Color(0xFF54A9EB) // Голубой цвет + val infiniteTransition = rememberInfiniteTransition(label = "typing") + val typingColor = Color(0xFF54A9EB) // Голубой цвет - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text(text = "typing", fontSize = 13.sp, color = typingColor) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text(text = "typing", fontSize = 13.sp, color = typingColor) - // 3 анимированные точки - repeat(3) { index -> - val offsetY by - infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = -4f, - animationSpec = - infiniteRepeatable( - animation = - tween( - durationMillis = 600, - delayMillis = index * 100, - easing = FastOutSlowInEasing - ), - repeatMode = RepeatMode.Reverse - ), - label = "dot$index" - ) + // 3 анимированные точки + repeat(3) { index -> + val offsetY by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -4f, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = 600, + delayMillis = index * 100, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "dot$index" + ) - Text( - text = ".", - fontSize = 13.sp, - color = typingColor, - modifier = Modifier.offset(y = offsetY.dp) - ) + Text( + text = ".", + fontSize = 13.sp, + color = typingColor, + modifier = Modifier.offset(y = offsetY.dp) + ) + } } - } } /** @@ -2907,72 +3689,72 @@ fun TypingIndicator(isDarkTheme: Boolean) { */ @Composable fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) { - // 🔥 Серый цвет для всех пузырьков (нейтральный скелетон) - val skeletonColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE0E0E0) + // 🔥 Серый цвет для всех пузырьков (нейтральный скелетон) + val skeletonColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE0E0E0) - // Shimmer анимация - val infiniteTransition = rememberInfiniteTransition(label = "shimmer") - val shimmerAlpha by - infiniteTransition.animateFloat( - initialValue = 0.4f, - targetValue = 0.8f, - animationSpec = - infiniteRepeatable( - animation = tween(800, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "shimmerAlpha" - ) + // Shimmer анимация + val infiniteTransition = rememberInfiniteTransition(label = "shimmer") + val shimmerAlpha by + infiniteTransition.animateFloat( + initialValue = 0.4f, + targetValue = 0.8f, + animationSpec = + infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "shimmerAlpha" + ) - // 🔥 Box с выравниванием внизу - как настоящий чат - Box(modifier = modifier) { - Column( - modifier = - Modifier.align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(bottom = 80.dp), // 🔥 Отступ от инпута - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - // Паттерн сообщений снизу вверх (как в реальном чате) - серые пузырьки - SkeletonBubble( - isOutgoing = true, - widthFraction = 0.45f, - bubbleColor = skeletonColor, - alpha = shimmerAlpha - ) - SkeletonBubble( - isOutgoing = false, - widthFraction = 0.55f, - bubbleColor = skeletonColor, - alpha = shimmerAlpha - ) - SkeletonBubble( - isOutgoing = true, - widthFraction = 0.35f, - bubbleColor = skeletonColor, - alpha = shimmerAlpha - ) - SkeletonBubble( - isOutgoing = false, - widthFraction = 0.50f, - bubbleColor = skeletonColor, - alpha = shimmerAlpha - ) - SkeletonBubble( - isOutgoing = true, - widthFraction = 0.60f, - bubbleColor = skeletonColor, - alpha = shimmerAlpha - ) - SkeletonBubble( - isOutgoing = false, - widthFraction = 0.40f, - bubbleColor = skeletonColor, - alpha = shimmerAlpha - ) + // 🔥 Box с выравниванием внизу - как настоящий чат + Box(modifier = modifier) { + Column( + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = 80.dp), // 🔥 Отступ от инпута + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Паттерн сообщений снизу вверх (как в реальном чате) - серые пузырьки + SkeletonBubble( + isOutgoing = true, + widthFraction = 0.45f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) + SkeletonBubble( + isOutgoing = false, + widthFraction = 0.55f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) + SkeletonBubble( + isOutgoing = true, + widthFraction = 0.35f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) + SkeletonBubble( + isOutgoing = false, + widthFraction = 0.50f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) + SkeletonBubble( + isOutgoing = true, + widthFraction = 0.60f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) + SkeletonBubble( + isOutgoing = false, + widthFraction = 0.40f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) + } } - } } /** Пузырёк-скелетон сообщения (толстый как настоящий с текстом) */ @@ -2983,26 +3765,29 @@ private fun SkeletonBubble( bubbleColor: Color, alpha: Float ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start - ) { - Box( - modifier = - Modifier.fillMaxWidth(widthFraction) - .defaultMinSize(minHeight = 44.dp) // Минимум как пузырёк с текстом - .clip( - RoundedCornerShape( - topStart = 18.dp, - topEnd = 18.dp, - bottomStart = if (isOutgoing) 18.dp else 6.dp, - bottomEnd = if (isOutgoing) 6.dp else 18.dp + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start + ) { + Box( + modifier = + Modifier.fillMaxWidth(widthFraction) + .defaultMinSize( + minHeight = 44.dp + ) // Минимум как пузырёк с текстом + .clip( + RoundedCornerShape( + topStart = 18.dp, + topEnd = 18.dp, + bottomStart = + if (isOutgoing) 18.dp else 6.dp, + bottomEnd = if (isOutgoing) 6.dp else 18.dp + ) ) - ) - .background(bubbleColor.copy(alpha = alpha)) - .padding(horizontal = 14.dp, vertical = 10.dp) - ) - } + .background(bubbleColor.copy(alpha = alpha)) + .padding(horizontal = 14.dp, vertical = 10.dp) + ) + } } /** @@ -3016,73 +3801,73 @@ private fun ModernPopupMenu( isDarkTheme: Boolean, content: @Composable ColumnScope.() -> Unit ) { - if (!expanded) return + if (!expanded) return - // Анимация появления в стиле Telegram - val scale by - animateFloatAsState( - targetValue = if (expanded) 1f else 0.3f, - animationSpec = spring(dampingRatio = 0.75f, stiffness = 350f), - label = "scale" - ) + // Анимация появления в стиле Telegram + val scale by + animateFloatAsState( + targetValue = if (expanded) 1f else 0.3f, + animationSpec = spring(dampingRatio = 0.75f, stiffness = 350f), + label = "scale" + ) - val alpha by - animateFloatAsState( - targetValue = if (expanded) 1f else 0f, - animationSpec = tween(150, easing = FastOutSlowInEasing), - label = "alpha" - ) + val alpha by + animateFloatAsState( + targetValue = if (expanded) 1f else 0f, + animationSpec = tween(150, easing = FastOutSlowInEasing), + label = "alpha" + ) - // Цвета меню с акцентным оттенком - val menuBackgroundColor = - if (isDarkTheme) { - Color(0xFF212121) // Telegram dark menu - } else { - Color.White - } + // Цвета меню с акцентным оттенком + val menuBackgroundColor = + if (isDarkTheme) { + Color(0xFF212121) // Telegram dark menu + } else { + Color.White + } - val accentBorderColor = PrimaryBlue.copy(alpha = 0.3f) // Тонкая акцентная обводка + val accentBorderColor = PrimaryBlue.copy(alpha = 0.3f) // Тонкая акцентная обводка - Popup( - alignment = Alignment.TopEnd, - offset = IntOffset(-16, 60), // Отступ от кнопки меню - onDismissRequest = onDismissRequest, - properties = - PopupProperties( - focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = true - ) - ) { - Column( - modifier = - Modifier.graphicsLayer { - scaleX = scale - scaleY = scale - this.alpha = alpha - transformOrigin = - TransformOrigin( - 1f, - 0f - ) // Анимация от правого верхнего угла - } - .width(220.dp) - .shadow( - elevation = 8.dp, - shape = RoundedCornerShape(12.dp), - spotColor = PrimaryBlue.copy(alpha = 0.2f), - ambientColor = PrimaryBlue.copy(alpha = 0.1f) - ) - .border( - width = 1.dp, - color = accentBorderColor, - shape = RoundedCornerShape(12.dp) - ) - .clip(RoundedCornerShape(12.dp)) - .background(menuBackgroundColor) - .padding(vertical = 8.dp) - ) { content() } - } + Popup( + alignment = Alignment.TopEnd, + offset = IntOffset(-16, 60), // Отступ от кнопки меню + onDismissRequest = onDismissRequest, + properties = + PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + Column( + modifier = + Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + transformOrigin = + TransformOrigin( + 1f, + 0f + ) // Анимация от правого верхнего угла + } + .width(220.dp) + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(12.dp), + spotColor = PrimaryBlue.copy(alpha = 0.2f), + ambientColor = PrimaryBlue.copy(alpha = 0.1f) + ) + .border( + width = 1.dp, + color = accentBorderColor, + shape = RoundedCornerShape(12.dp) + ) + .clip(RoundedCornerShape(12.dp)) + .background(menuBackgroundColor) + .padding(vertical = 8.dp) + ) { content() } + } } /** 🔥 Элемент меню для Bottom Sheet Красивый дизайн с большими отступами для удобного нажатия */ @@ -3095,150 +3880,151 @@ private fun BottomSheetMenuItem( tintColor: Color = if (isDarkTheme) Color.White else Color.Black, isDestructive: Boolean = false ) { - val actualTintColor = if (isDestructive) Color(0xFFFF3B30) else tintColor - val textColor = - if (isDestructive) { - Color(0xFFFF3B30) - } else if (isDarkTheme) { - Color.White - } else { - Color.Black - } + val actualTintColor = if (isDestructive) Color(0xFFFF3B30) else tintColor + val textColor = + if (isDestructive) { + Color(0xFFFF3B30) + } else if (isDarkTheme) { + Color.White + } else { + Color.Black + } - // Ripple эффект при нажатии - val interactionSource = remember { MutableInteractionSource() } - val isPressed = interactionSource.collectIsPressedAsState() + // Ripple эффект при нажатии + val interactionSource = remember { MutableInteractionSource() } + val isPressed = interactionSource.collectIsPressedAsState() - val backgroundColor = - if (isPressed.value) { - if (isDarkTheme) Color.White.copy(alpha = 0.08f) - else Color.Black.copy(alpha = 0.04f) - } else { - Color.Transparent - } + val backgroundColor = + if (isPressed.value) { + if (isDarkTheme) Color.White.copy(alpha = 0.08f) + else Color.Black.copy(alpha = 0.04f) + } else { + Color.Transparent + } - Row( - modifier = - Modifier.fillMaxWidth() - .background(backgroundColor) - .clickable(interactionSource = interactionSource, indication = null) { - onClick() - } - .padding(horizontal = 24.dp, vertical = 20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = actualTintColor, - modifier = Modifier.size(28.dp) - ) - Spacer(modifier = Modifier.width(20.dp)) - Text(text = text, color = textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium) - } + Row( + modifier = + Modifier.fillMaxWidth() + .background(backgroundColor) + .clickable( + interactionSource = interactionSource, + indication = null + ) { onClick() } + .padding(horizontal = 24.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = actualTintColor, + modifier = Modifier.size(28.dp) + ) + Spacer(modifier = Modifier.width(20.dp)) + Text( + text = text, + color = textColor, + fontSize = 18.sp, + fontWeight = FontWeight.Medium + ) + } } -/** - * 🔥 TELEGRAM-STYLE KEBAB MENU - * Красивое выпадающее меню с анимациями и современным дизайном - */ +/** 🔥 TELEGRAM-STYLE KEBAB MENU Красивое выпадающее меню с анимациями и современным дизайном */ @Composable private fun KebabMenu( - expanded: Boolean, - onDismiss: () -> Unit, - isDarkTheme: Boolean, - isSavedMessages: Boolean, - isBlocked: Boolean, - onBlockClick: () -> Unit, - onUnblockClick: () -> Unit, - onDeleteClick: () -> Unit + expanded: Boolean, + onDismiss: () -> Unit, + isDarkTheme: Boolean, + isSavedMessages: Boolean, + isBlocked: Boolean, + onBlockClick: () -> Unit, + onUnblockClick: () -> Unit, + onDeleteClick: () -> Unit ) { - val menuBackgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White - val textColor = if (isDarkTheme) Color.White else Color.Black - val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) - - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismiss, - modifier = Modifier.width(220.dp), - properties = PopupProperties( - focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = true - ) - ) { - // Block/Unblock User (не для Saved Messages) - if (!isSavedMessages) { - KebabMenuItem( - icon = if (isBlocked) Icons.Default.CheckCircle else Icons.Default.Block, - text = if (isBlocked) "Unblock User" else "Block User", - onClick = { - if (isBlocked) onUnblockClick() else onBlockClick() - }, - tintColor = PrimaryBlue, - textColor = textColor - ) - - // Divider - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .height(0.5.dp) - .background(dividerColor) - ) + val menuBackgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White + val textColor = if (isDarkTheme) Color.White else Color.Black + val dividerColor = + if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + modifier = Modifier.width(220.dp), + properties = + PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + // Block/Unblock User (не для Saved Messages) + if (!isSavedMessages) { + KebabMenuItem( + icon = + if (isBlocked) Icons.Default.CheckCircle + else Icons.Default.Block, + text = if (isBlocked) "Unblock User" else "Block User", + onClick = { if (isBlocked) onUnblockClick() else onBlockClick() }, + tintColor = PrimaryBlue, + textColor = textColor + ) + + // Divider + Box( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .height(0.5.dp) + .background(dividerColor) + ) + } + + // Delete Chat + KebabMenuItem( + icon = Icons.Default.Delete, + text = "Delete Chat", + onClick = onDeleteClick, + tintColor = Color(0xFFFF3B30), + textColor = Color(0xFFFF3B30) + ) } - - // Delete Chat - KebabMenuItem( - icon = Icons.Default.Delete, - text = "Delete Chat", - onClick = onDeleteClick, - tintColor = Color(0xFFFF3B30), - textColor = Color(0xFFFF3B30) - ) - } } -/** - * 🔥 Элемент Kebab меню - */ +/** 🔥 Элемент Kebab меню */ @Composable private fun KebabMenuItem( - icon: ImageVector, - text: String, - onClick: () -> Unit, - tintColor: Color, - textColor: Color + icon: ImageVector, + text: String, + onClick: () -> Unit, + tintColor: Color, + textColor: Color ) { - val interactionSource = remember { MutableInteractionSource() } - - DropdownMenuItem( - text = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tintColor, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(14.dp)) - Text( - text = text, - color = textColor, - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) - } - }, - onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - interactionSource = interactionSource, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) - ) + val interactionSource = remember { MutableInteractionSource() } + + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tintColor, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(14.dp)) + Text( + text = text, + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + }, + onClick = onClick, + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + interactionSource = interactionSource, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) + ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 8b5d65b..1522017 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -235,9 +235,6 @@ fun ChatsListScreen( var showStatusDialog by remember { mutableStateOf(false) } val debugLogs by ProtocolManager.debugLogs.collectAsState() - // 📱 FCM токен диалог - var showFcmDialog by remember { mutableStateOf(false) } - // 📬 Requests screen state var showRequestsScreen by remember { mutableStateOf(false) } @@ -356,88 +353,6 @@ fun ChatsListScreen( ) } - // FCM Logs dialog - if (showFcmDialog) { - val fcmLogs = com.rosetta.messenger.MainActivity.fcmLogs - AlertDialog( - onDismissRequest = { showFcmDialog = false }, - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "🔔 FCM Token Logs", - fontWeight = FontWeight.Bold, - color = textColor - ) - if (fcmLogs.isNotEmpty()) { - TextButton( - onClick = { - com.rosetta.messenger.MainActivity - .clearFcmLogs() - }, - contentPadding = - PaddingValues(horizontal = 8.dp) - ) { - Text( - "Clear", - fontSize = 13.sp, - color = PrimaryBlue - ) - } - } - } - }, - text = { - Column(modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp)) { - if (fcmLogs.isEmpty()) { - Text( - text = - "No FCM logs yet...\n\nLogs will appear here when Firebase initializes and FCM token is sent to the server.", - fontSize = 14.sp, - color = secondaryTextColor, - textAlign = TextAlign.Center, - modifier = - Modifier.padding(vertical = 20.dp) - ) - } else { - LazyColumn { - items(fcmLogs.size) { index -> - val log = fcmLogs[index] - Text( - text = log, - fontSize = 12.sp, - color = textColor, - fontFamily = - FontFamily - .Monospace, - lineHeight = 18.sp, - modifier = - Modifier.padding( - vertical = - 2.dp - ) - ) - } - } - } - } - }, - confirmButton = { - Button( - onClick = { showFcmDialog = false }, - colors = - ButtonDefaults.buttonColors( - containerColor = PrimaryBlue - ) - ) { Text("Close", color = Color.White) } - }, - containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White - ) - } - // Simple background Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) { ModalNavigationDrawer( @@ -701,7 +616,7 @@ fun ChatsListScreen( // 📖 Saved Messages DrawerMenuItemEnhanced( - icon = Icons.Outlined.Bookmark, + icon = Icons.Default.Bookmark, text = "Saved Messages", iconColor = menuIconColor, textColor = textColor, @@ -720,64 +635,6 @@ fun ChatsListScreen( DrawerDivider(isDarkTheme) - // 👥 Contacts - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Contacts, - text = "Contacts", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - onContactsClick() - } - ) - - // 📞 Calls - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Call, - text = "Calls", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - onCallsClick() - } - ) - - // ➕ Invite Friends - DrawerMenuItemEnhanced( - icon = Icons.Outlined.PersonAdd, - text = "Invite Friends", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - onInviteFriendsClick() - } - ) - - DrawerDivider(isDarkTheme) - - // 🔔 FCM Logs - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Notifications, - text = "FCM Token Logs", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - showFcmDialog = true - } - ) - // ⚙️ Settings DrawerMenuItemEnhanced( icon = Icons.Outlined.Settings, @@ -809,21 +666,6 @@ fun ChatsListScreen( textColor = textColor, onClick = { onToggleTheme() } ) - - // ❓ Help - DrawerMenuItemEnhanced( - icon = Icons.Outlined.HelpOutline, - text = "Help & FAQ", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - // TODO: Add help screen - // navigation - } - ) } // ═══════════════════════════════════════════════════════════