diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 5543520..3cd9872 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -687,7 +687,6 @@ fun MainScreen( user = selectedUser!!, currentUserPublicKey = accountPublicKey, currentUserPrivateKey = accountPrivateKey, - isDarkTheme = isDarkTheme, onBack = { selectedUser = null }, onUserProfileClick = { user -> // Открываем профиль другого пользователя 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 369a29e..09163af 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 @@ -1,19 +1,23 @@ package com.rosetta.messenger.ui.chats +import android.content.ClipboardManager import android.content.Context import android.view.inputmethod.InputMethodManager import androidx.activity.compose.BackHandler -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -24,347 +28,106 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.Stable -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalClipboardManager 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 import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel -import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition -import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator -import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator -import com.airbnb.lottie.compose.* +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition import com.rosetta.messenger.R -import com.rosetta.messenger.data.ForwardManager -import com.rosetta.messenger.data.Message -import com.rosetta.messenger.network.DeliveryStatus -import com.rosetta.messenger.network.SearchUser -import com.rosetta.messenger.ui.components.AppleEmojiText -import com.rosetta.messenger.ui.components.AppleEmojiTextField -import com.rosetta.messenger.ui.components.OptimizedEmojiPicker +import com.rosetta.messenger.database.RosettaDatabase +import com.rosetta.messenger.ui.chats.models.* +import com.rosetta.messenger.ui.chats.utils.* +import com.rosetta.messenger.ui.chats.components.* +import com.rosetta.messenger.ui.chats.input.* import com.rosetta.messenger.ui.components.VerifiedBadge +import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.ui.onboarding.PrimaryBlue -import java.text.SimpleDateFormat -import java.util.* +import com.rosetta.messenger.data.ForwardManager +import com.rosetta.messenger.ui.chats.ForwardChatPickerBottomSheet +import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator +import androidx.compose.runtime.collectAsState import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale -// Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910) -val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f) - -// 🚀 Помечаем классы как Stable для оптимизации рекомпозиций -@Stable class StableSearchUser(val user: SearchUser) - -/** 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() - } - 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() - -/** Данные цитируемого сообщения */ -data class ReplyData( - val messageId: String, - val senderName: String, // Имя отправителя цитируемого сообщения - val text: String, - val isFromMe: Boolean, // Цитируемое сообщение от меня? - val isForwarded: Boolean = false // Это пересланное сообщение? -) - -/** Модель сообщения (Legacy - для совместимости) */ -data class ChatMessage( - val id: String, - val text: String, - val isOutgoing: Boolean, - val timestamp: Date, - val status: MessageStatus = MessageStatus.SENT, - val showDateHeader: Boolean = false, // Показывать ли разделитель даты - val replyData: ReplyData? = null // Данные цитируемого сообщения -) - -enum class MessageStatus { - SENDING, - SENT, - DELIVERED, - READ, - ERROR // 🔥 Ошибка отправки (таймаут или реальная ошибка) -} - -// 🔥 Константа таймаута доставки (как в архиве - 80 секунд) -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 -} - -/** Получить текст даты (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) } - - 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 для конвертации -private fun Message.toChatMessage() = - ChatMessage( - id = messageId, - text = content, - isOutgoing = isFromMe, - timestamp = Date(timestamp), - status = - when (deliveryStatus) { - DeliveryStatus.WAITING -> MessageStatus.SENDING - DeliveryStatus.DELIVERED -> - if (isRead) MessageStatus.READ else MessageStatus.DELIVERED - DeliveryStatus.ERROR -> MessageStatus.SENT - } - ) - -/** Экран детального чата с пользователем */ @OptIn( - ExperimentalMaterial3Api::class, - ExperimentalComposeUiApi::class, - ExperimentalFoundationApi::class + ExperimentalMaterial3Api::class, + androidx.compose.foundation.ExperimentalFoundationApi::class, + androidx.compose.ui.ExperimentalComposeUiApi::class ) @Composable fun ChatDetailScreen( user: SearchUser, + onBack: () -> Unit, + onNavigateToChat: (SearchUser) -> Unit, + onUserProfileClick: (SearchUser) -> Unit = {}, currentUserPublicKey: String, currentUserPrivateKey: String, - isDarkTheme: Boolean, - onBack: () -> Unit, - onUserProfileClick: (SearchUser) -> Unit = {}, - onNavigateToChat: (SearchUser) -> Unit = {}, // 📨 Callback для навигации в другой чат (Forward) - viewModel: ChatViewModel = viewModel() + totalUnreadFromOthers: Int = 0 ) { - // 🔥 ОПТИМИЗАЦИЯ: Убрано логирование из композиции чтобы не засорять logcat - + val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") + val context = LocalContext.current + val scope = rememberCoroutineScope() val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current - val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val database = RosettaDatabase.getDatabase(context) + + // UI Theme + val isDarkTheme = androidx.compose.foundation.isSystemInDarkTheme() + val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7) + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) + val headerIconColor = if (isDarkTheme) Color(0xFF0A84FF) else Color(0xFF007AFF) + + // 🔥 Keyboard & Emoji Coordinator + val coordinator = remember { KeyboardTransitionCoordinator() } 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() - } - } - } - - // 🔥 Инициализируем высоту 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 + // 🔥 Focus state for input val inputFocusRequester = remember { FocusRequester() } - // 🔥 Автофокус на инпут при появлении reply панели - LaunchedEffect(hasReply) { - if (hasReply) { - try { - inputFocusRequester.requestFocus() - } catch (e: Exception) { - // Игнорируем если фокус не удался - } - } - } + // 🔥 Emoji picker state + var showEmojiPicker by remember { mutableStateOf(false) } - // Telegram-style scroll tracking - var wasManualScroll by remember { mutableStateOf(false) } - // Кнопка появляется после 3+ сообщений от начала (позже) - val isAtBottom by remember { - derivedStateOf { - listState.firstVisibleItemIndex < 3 && - listState.firstVisibleItemScrollOffset < 100 - } - } - // Флаг для скрытия кнопки scroll при отправке (чтобы не мигала) + // 🔥 List state с начальной позицией 0 (самое новое сообщение) + val listState = rememberLazyListState(initialFirstVisibleItemIndex = 0) + + // 🔥 Состояние для отслеживания отправки сообщения (блокирует scroll-to-top) var isSendingMessage by remember { mutableStateOf(false) } + // 🔥 Отслеживание ручной прокрутки + var wasManualScroll by remember { mutableStateOf(false) } + + // 🔥 Подсветка сообщения при клике на reply + var highlightedMessageId by remember { mutableStateOf(null) } + // 🔥 MESSAGE SELECTION STATE - для Reply/Forward var selectedMessages by remember { mutableStateOf>(emptySet()) } val isSelectionMode = selectedMessages.isNotEmpty() @@ -433,9 +196,27 @@ fun ChatDetailScreen( val isOnline by viewModel.opponentOnline.collectAsState() val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона - // 🔥 Reply/Forward state (replyMessages и hasReply определены выше для listBottomPadding) + // 🔥 Reply/Forward state + val replyMessages by viewModel.replyMessages.collectAsState() + val hasReply = replyMessages.isNotEmpty() val isForwardMode by viewModel.isForwardMode.collectAsState() + // 🔥 Display reply messages - получаем полную информацию о сообщениях для reply + val displayReplyMessages = + remember(replyMessages, messages) { + replyMessages.mapNotNull { replyMsg -> + messages.find { it.id == replyMsg.messageId }?.let { chatMsg -> + ChatViewModel.ReplyMessage( + messageId = chatMsg.id, + text = chatMsg.text, + timestamp = chatMsg.timestamp.time, + isOutgoing = chatMsg.isOutgoing, + publicKey = if (chatMsg.isOutgoing) currentUserPublicKey else user.publicKey + ) + } + } + } + // 🔥 Добавляем информацию о датах к сообщениям // В reversed layout (новые внизу) - показываем дату ПОСЛЕ сообщения (визуально сверху) val messagesWithDates = @@ -474,10 +255,6 @@ fun ChatDetailScreen( // 🔥 Функция для скролла к сообщению с подсветкой val scrollToMessage: (String) -> Unit = { messageId -> - - // Логируем все ID сообщений для отладки - messagesWithDates.forEachIndexed { index, pair -> } - scope.launch { // 🔥 Сбрасываем текущую подсветку перед новым скроллом highlightedMessageId = null @@ -494,7 +271,7 @@ fun ChatDetailScreen( highlightedMessageId = messageId delay(2000) highlightedMessageId = null - } else {} + } } } @@ -1229,37 +1006,19 @@ fun ChatDetailScreen( } // Закрытие Box unified header }, containerColor = backgroundColor, // Фон всего чата - // 🔥 Bottom bar - инпут с умным padding: - // - Когда showEmojiPicker=false → imePadding (поднимается над клавиатурой) - // - Когда showEmojiPicker=true → НЕТ imePadding (Box с эмодзи сам даёт - // высоту) + // 🔥 Bottom bar - инпут с умным padding 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 - клавиатура - // поднимает инпут + Modifier.imePadding() } else { - Modifier // Без imePadding + Modifier } - // Логирование состояния - LaunchedEffect( - isSelectionMode, - useImePadding, - coordinator.isEmojiBoxVisible, - coordinator.keyboardHeight - ) {} - Column(modifier = bottomModifier) { - // 🔥 UNIFIED BOTTOM BAR - один контейнер, контент меняется - // внутри с плавной - // анимацией + // 🔥 UNIFIED BOTTOM BAR AnimatedContent( targetState = isSelectionMode, transitionSpec = { @@ -1272,9 +1031,6 @@ fun ChatDetailScreen( ) { selectionMode -> if (selectionMode) { // SELECTION ACTION BAR - Reply/Forward - // 🔥 Высота должна совпадать с - // MessageInputBar (~56dp content + nav - // bar) Column( modifier = Modifier.fillMaxWidth() @@ -1306,8 +1062,6 @@ fun ChatDetailScreen( ) ) - // Кнопки Reply и Forward - плавная - // анимация появления val buttonScale by animateFloatAsState( targetValue = @@ -1351,8 +1105,7 @@ fun ChatDetailScreen( Alignment .CenterVertically ) { - // Reply button - идентичной - // высоты с инпутом + // Reply button Box( modifier = Modifier.weight( @@ -1435,9 +1188,7 @@ fun ChatDetailScreen( } } - // Forward button - - // идентичной высоты с - // инпутом + // Forward button Box( modifier = Modifier.weight( @@ -1459,9 +1210,6 @@ fun ChatDetailScreen( ) ) .clickable { - // 📨 Сохраняем сообщения в - // ForwardManager и показываем - // выбор чата val selectedMsgs = messages .filter { @@ -1626,13 +1374,10 @@ fun ChatDetailScreen( } ) { paddingValues -> // 🔥 Column структура - список сжимается когда клавиатура открывается - // imePadding применён к bottomBar, поэтому контент автоматически сжимается Column( modifier = Modifier.fillMaxSize() - .padding( - paddingValues - ) // 🔥 Учитываем top и bottom padding от Scaffold + .padding(paddingValues) .background(backgroundColor) ) { // Список сообщений - занимает всё доступное место @@ -1783,13 +1528,6 @@ fun ChatDetailScreen( } } ), - // padding для контента списка - // 🔥 Убираем horizontal padding - // чтобы выделение было - // edge-to-edge - // 🔥 Увеличиваем bottom padding - // когда активен selection - // mode (Reply/Forward панель) contentPadding = PaddingValues( start = 0.dp, @@ -1799,21 +1537,10 @@ fun ChatDetailScreen( if (isSelectionMode ) 100.dp - else - 16.dp // 🔥 Уменьшено для - // инпута - ), + else 16.dp + ), reverseLayout = true ) { - // Reversed layout: item 0 = самое - // новое сообщение (внизу - // экрана) - // messagesWithDates уже - // отсортирован новые->старые - // 🔥 Используем только id как ключ - // - без index, чтобы избежать - // прыгания при добавлении новых - // сообщений itemsIndexed( messagesWithDates, key = { _, item -> @@ -1822,8 +1549,7 @@ fun ChatDetailScreen( ) { index, (message, showDate) -> // Определяем, показывать ли // хвостик (последнее - // сообщение в - // группе) + // сообщение в группе) val nextMessage = messagesWithDates .getOrNull( @@ -1842,16 +1568,8 @@ fun ChatDetailScreen( nextMessage .timestamp .time) > - 60_000 // 1 минута + 60_000 - // 🚀 ОПТИМИЗАЦИЯ: - // animateItemPlacement() - // для плавной - // анимации при - // добавлении/удалении - // Это предотвращает - // "прыжки" пузырьков при - // изменении списка Column( modifier = Modifier.animateItemPlacement( @@ -1864,12 +1582,6 @@ fun ChatDetailScreen( ) ) ) { - // В reversed - // layout: дата - // показывается - // ПОСЛЕ сообщения - // (визуально СВЕРХУ - // группы сообщений) if (showDate) { DateHeader( dateText = @@ -1881,11 +1593,6 @@ fun ChatDetailScreen( secondaryTextColor ) } - // 🔥 Ключ для - // выделения - - // используем только - // ID (как и в - // key списка) val selectionKey = message.id MessageBubble( @@ -1904,11 +1611,8 @@ fun ChatDetailScreen( highlightedMessageId == message.id, isSavedMessages = - isSavedMessages, // 📁 Передаем флаг Saved Messages + isSavedMessages, onLongClick = { - - // 🔥 СНАЧАЛА закрываем клавиатуру МГНОВЕННО - // (до изменения state) if (!isSelectionMode ) { val imm = @@ -1925,7 +1629,6 @@ fun ChatDetailScreen( showEmojiPicker = false } - // Toggle selection on long press selectedMessages = if (selectedMessages .contains( @@ -1940,7 +1643,6 @@ fun ChatDetailScreen( } }, onClick = { - // If in selection mode, toggle selection if (isSelectionMode ) { selectedMessages = @@ -1958,8 +1660,6 @@ fun ChatDetailScreen( } }, onSwipeToReply = { - // 🔥 Swipe-to-reply: добавляем это - // сообщение в reply viewModel .setReplyMessages( listOf( @@ -1970,21 +1670,17 @@ fun ChatDetailScreen( onReplyClick = { messageId -> - // 🔥 Клик на цитату - скроллим к сообщению scrollToMessage( messageId ) }, onRetry = { - // 🔥 Retry: удаляем старое и отправляем - // заново viewModel .retryMessage( message ) }, onDelete = { - // 🔥 Delete: удаляем сообщение viewModel .deleteMessage( message.id @@ -1998,7 +1694,7 @@ fun ChatDetailScreen( } } } - } // Закрытие Box с fade-in + } // Закрытие Box // Диалог подтверждения удаления чата if (showDeleteConfirm) { @@ -2020,22 +1716,7 @@ fun ChatDetailScreen( 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 @@ -2044,10 +1725,6 @@ fun ChatDetailScreen( } else { "${user.publicKey}:$currentUserPublicKey" } - android.util.Log.d( - "ChatDetail", - "🗑️ dialogKey=$dialogKey" - ) // 🗑️ Очищаем ВСЕ кэши сообщений com.rosetta.messenger.data @@ -2056,69 +1733,27 @@ fun ChatDetailScreen( .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( + database.messageDao() + .deleteDialog( + account = currentUserPublicKey, + dialogKey = dialogKey - ) - android.util.Log.d( - "ChatDetail", - "🗑️ Messages AFTER delete: $countAfter" - ) + ) + database.messageDao() + .deleteMessagesBetweenUsers( + account = + currentUserPublicKey, + user1 = + user.publicKey, + user2 = + currentUserPublicKey + ) // Очищаем кеш диалога database.dialogDao() @@ -2128,35 +1763,9 @@ fun ChatDetailScreen( 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 - ) + // Error deleting chat } - // Выходим ПОСЛЕ удаления - закрываем - // клавиатуру hideKeyboardAndBack() } } @@ -2194,8 +1803,6 @@ fun ChatDetailScreen( showBlockConfirm = false scope.launch { try { - // Добавляем пользователя в - // blacklist database.blacklistDao() .blockUser( com.rosetta @@ -2248,7 +1855,6 @@ fun ChatDetailScreen( showUnblockConfirm = false scope.launch { try { - // Удаляем пользователя из blacklist database.blacklistDao() .unblockUser( publicKey = @@ -2284,7 +1890,6 @@ fun ChatDetailScreen( }, onChatSelected = { selectedDialog -> showForwardPicker = false - // Переходим в выбранный чат с полными данными ForwardManager.selectChat(selectedDialog.opponentKey) val searchUser = SearchUser( title = selectedDialog.opponentTitle, @@ -2298,1745 +1903,3 @@ fun ChatDetailScreen( ) } } - -/** 🚀 Анимация появления сообщения Telegram-style */ -@Composable -fun rememberMessageEnterAnimation(messageId: String): Pair { - 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 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 - } - - return Pair(alpha, translationY) -} - -/** 🚀 Пузырек сообщения Telegram-style - ОПТИМИЗИРОВАННЫЙ */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun MessageBubble( - message: ChatMessage, - isDarkTheme: Boolean, - showTail: Boolean = true, - isSelected: Boolean = false, - isHighlighted: Boolean = false, // 🔥 Подсветка при клике на reply - isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages - onLongClick: () -> Unit = {}, - onClick: () -> Unit = {}, - onSwipeToReply: () -> Unit = {}, - onReplyClick: (String) -> Unit = {}, // 🔥 Клик на цитату - скролл к сообщению - 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 // Максимальный сдвиг - - // Анимация возврата - 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 - ) - } - - // 🔥 ОПТИМИЗАЦИЯ: SimpleDateFormat создаём один раз - val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } - - // 🔥 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) - } - ) - } - ) { - // 🔥 Reply icon (появляется справа при свайпе влево) - Box( - modifier = - Modifier.align(Alignment.CenterEnd) - .padding(end = 16.dp) - .graphicsLayer { - alpha = swipeProgress - scaleX = swipeProgress - scaleY = swipeProgress - } - ) { - 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) - ) { - 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 = true) - ) - - // Время и статус справа - 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 -} - -/** - * 🎯 Анимированный статус сообщения с плавными переходами Поддерживает ERROR статус с красной - * иконкой (как в архиве) - */ -@Composable -private fun AnimatedMessageStatus( - status: MessageStatus, - timeColor: Color, - timestamp: Long = 0L, - onRetry: () -> Unit = {}, - onDelete: () -> Unit = {} -) { - // 🔥 Проверяем таймаут для 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" - ) - - // Анимация 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 -> - // 🔥 Фиксированный размер 14sp для всех иконок статуса - val iconSize = with(LocalDensity.current) { 14.sp.toDp() } - - 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(iconSize) - .scale(scale) - .then( - if (currentStatus == MessageStatus.ERROR) { - Modifier.clickable { - showErrorMenu = true - } - } else Modifier - ) - ) - } - - // 🔥 Меню ошибки (как в архиве) - 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 (цитата) внутри сообщения Стиль: вертикальная линия слева + имя + текст */ -@Composable -private fun ReplyBubble( - replyData: ReplyData, - isOutgoing: Boolean, - isDarkTheme: Boolean, - onClick: () -> Unit = {} // 🔥 Клик на цитату -) { - // НАШИ ОРИГИНАЛЬНЫЕ ЦВЕТА - 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 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 - } - - Row( - modifier = - Modifier.fillMaxWidth() - .height(IntrinsicSize.Min) - .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = onClick) // 🔥 Клик на цитату - .background(backgroundColor) - ) { - // 🔥 TELEGRAM: Вертикальная линия слева 3dp - Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor)) - - // Контент - Column( - modifier = - Modifier.fillMaxWidth() - // 🔥 TELEGRAM: padding как в дизайне - .padding( - start = 8.dp, - end = 10.dp, - top = 4.dp, - bottom = 4.dp - ) - ) { - // 🔥 TELEGRAM: Имя или "Forwarded message" 14sp, Medium weight - Text( - text = if (replyData.isForwarded) "Forwarded message" else 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" - ) - - 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, - modifier = - Modifier.background( - color = secondaryTextColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(12.dp) - ) - .padding(horizontal = 12.dp, vertical = 4.dp) - ) - } -} - -/** - * Панель ввода сообщения - Telegram UX канон - * - * Золотые правила: - * 1. Инпут всегда связан с клавиатурой (imePadding) - * 2. Последнее сообщение всегда видно - * 3. Никаких прыжков layout'а - * 4. После отправки: инпут очищается, клавиатура НЕ закрывается - * 5. Инпут растёт вверх при многострочном тексте (до 6 строк) - */ -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun MessageInputBar( - value: String, - onValueChange: (String) -> Unit, - onSend: () -> Unit, - isDarkTheme: Boolean, - backgroundColor: Color, - textColor: Color, - placeholderColor: Color, - secondaryTextColor: Color, - // Reply state (как в React Native) - replyMessages: List = emptyList(), - isForwardMode: Boolean = false, - onCloseReply: () -> Unit = {}, - chatTitle: String = "", - isBlocked: Boolean = false, - // Emoji picker state (поднят для KeyboardAvoidingView) - showEmojiPicker: Boolean = false, - onToggleEmojiPicker: (Boolean) -> Unit = {}, - // Focus requester для автофокуса при reply - focusRequester: FocusRequester? = null, - // Coordinator для плавных переходов клавиатуры - coordinator: KeyboardTransitionCoordinator, - // 🔥 Для отображения reply preview и скролла к сообщению - displayReplyMessages: List = emptyList(), - onReplyClick: (String) -> Unit = {} -) { - // 🔥 ОПТИМИЗАЦИЯ: Убрано логирование чтобы не засорять logcat - - 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 - - // 🔥 Ссылка на 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 - } - - 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 - ) - } - }, - 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) - ) - ) - - // 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 { - // 🔥 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) - ) - ) - - // 🔥 Когда 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) // Голубой цвет - - 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" - ) - - Text( - text = ".", - fontSize = 13.sp, - color = typingColor, - modifier = Modifier.offset(y = offsetY.dp) - ) - } - } -} - -/** - * 🔥 СКЕЛЕТОН для загрузки сообщений Показывает анимированные плейсхолдеры пока загружаются - * сообщения - */ -@Composable -fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) { - // 🔥 Серый цвет для всех пузырьков (нейтральный скелетон) - 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" - ) - - // 🔥 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 - ) - } - } -} - -/** Пузырёк-скелетон сообщения (толстый как настоящий с текстом) */ -@Composable -private fun SkeletonBubble( - isOutgoing: Boolean, - widthFraction: Float, - 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 - ) - ) - .background(bubbleColor.copy(alpha = alpha)) - .padding(horizontal = 14.dp, vertical = 10.dp) - ) - } -} - -/** - * 🔥 Современное выпадающее меню в стиле Telegram Использует Popup вместо Material DropdownMenu С - * красивыми анимациями как в оригинальном Telegram - */ -@Composable -private fun ModernPopupMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - isDarkTheme: Boolean, - content: @Composable ColumnScope.() -> Unit -) { - if (!expanded) return - - // Анимация появления в стиле 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 menuBackgroundColor = - if (isDarkTheme) { - Color(0xFF212121) // Telegram dark menu - } else { - Color.White - } - - 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() } - } -} - -/** 🔥 Элемент меню для Bottom Sheet Красивый дизайн с большими отступами для удобного нажатия */ -@Composable -private fun BottomSheetMenuItem( - icon: ImageVector, - text: String, - onClick: () -> Unit, - isDarkTheme: Boolean, - 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 - } - - // 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 - } - - 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 Красивое выпадающее меню с анимациями и современным дизайном */ -@Composable -private fun KebabMenu( - 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) - ) - } - - // Delete Chat - KebabMenuItem( - icon = Icons.Default.Delete, - text = "Delete Chat", - onClick = onDeleteClick, - tintColor = Color(0xFFFF3B30), - textColor = Color(0xFFFF3B30) - ) - } -} - -/** 🔥 Элемент Kebab меню */ -@Composable -private fun KebabMenuItem( - 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) - ) -} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index b6370a0..b1d5d6f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -10,6 +10,7 @@ import com.rosetta.messenger.database.DialogEntity import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.* +import com.rosetta.messenger.ui.chats.models.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import org.json.JSONArray diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt new file mode 100644 index 0000000..b074efe --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -0,0 +1,700 @@ +package com.rosetta.messenger.ui.chats.components + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.rosetta.messenger.ui.components.AppleEmojiText +import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import com.rosetta.messenger.ui.chats.models.* +import com.rosetta.messenger.ui.chats.utils.* +import kotlinx.coroutines.delay +import java.text.SimpleDateFormat +import java.util.* + +/** + * Reusable UI components for Chat Detail Screen + * Extracted from ChatDetailScreen.kt for better organization + */ + +/** Date header with fade-in animation */ +@Composable +fun DateHeader(dateText: String, secondaryTextColor: Color) { + 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 } + + 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, + modifier = Modifier + .background( + color = secondaryTextColor.copy(alpha = 0.1f), + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) + } +} + +/** Typing indicator with animated dots (Telegram style) */ +@Composable +fun TypingIndicator(isDarkTheme: Boolean) { + 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) + + 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) + ) + } + } +} + +/** Message bubble with Telegram-style design and animations */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MessageBubble( + message: ChatMessage, + isDarkTheme: Boolean, + showTail: Boolean = true, + isSelected: Boolean = false, + isHighlighted: Boolean = false, + isSavedMessages: Boolean = false, + onLongClick: () -> Unit = {}, + onClick: () -> Unit = {}, + onSwipeToReply: () -> Unit = {}, + onReplyClick: (String) -> Unit = {}, + onRetry: () -> Unit = {}, + onDelete: () -> Unit = {} +) { + // Swipe-to-reply state + var swipeOffset by remember { mutableStateOf(0f) } + val swipeThreshold = 80f + val maxSwipe = 120f + + val animatedOffset by animateFloatAsState( + targetValue = swipeOffset, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), + label = "swipeOffset" + ) + + val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f) + + // Selection animations + 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" + ) + + // Colors + 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) + } + + val bubbleShape = remember(message.isOutgoing, showTail) { + RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + 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 timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + + Box( + modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + if (swipeOffset <= -swipeThreshold) { + onSwipeToReply() + } + swipeOffset = 0f + }, + onDragCancel = { swipeOffset = 0f }, + onHorizontalDrag = { _, dragAmount -> + val newOffset = swipeOffset + dragAmount + swipeOffset = newOffset.coerceIn(-maxSwipe, 0f) + } + ) + } + ) { + // Reply icon + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp) + .graphicsLayer { + alpha = swipeProgress + scaleX = swipeProgress + scaleY = swipeProgress + } + ) { + 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) + ) + } + } + + val selectionBackgroundColor by animateColorAsState( + targetValue = if (isSelected) PrimaryBlue.copy(alpha = 0.15f) else Color.Transparent, + animationSpec = tween(200), + label = "selectionBg" + ) + + val highlightBackgroundColor by animateColorAsState( + targetValue = if (isHighlighted) PrimaryBlue.copy(alpha = 0.12f) else Color.Transparent, + animationSpec = tween(300), + label = "highlightBg" + ) + + val combinedBackgroundColor = if (isSelected) selectionBackgroundColor else highlightBackgroundColor + + Row( + modifier = Modifier + .fillMaxWidth() + .background(combinedBackgroundColor) + .padding(vertical = 2.dp) + .offset { IntOffset(animatedOffset.toInt(), 0) }, + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + // Selection checkmark + 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)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + } + } + + AnimatedVisibility( + visible = !isSelected, + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)) + ) { + Spacer(modifier = Modifier.width(12.dp)) + } + + if (message.isOutgoing) { + Spacer(modifier = Modifier.weight(1f)) + } + + Box( + modifier = Modifier + .padding(end = 12.dp) + .widthIn(max = 280.dp) + .graphicsLayer { + this.alpha = selectionAlpha + this.scaleX = selectionScale + this.scaleY = selectionScale + } + .combinedClickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick, + onLongClick = onLongClick + ) + .clip(bubbleShape) + .background(bubbleColor) + .padding(horizontal = 10.dp, vertical = 8.dp) + ) { + Column { + message.replyData?.let { reply -> + ReplyBubble( + replyData = reply, + isOutgoing = message.isOutgoing, + isDarkTheme = isDarkTheme, + onClick = { onReplyClick(reply.messageId) } + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 17.sp, + modifier = Modifier.weight(1f, fill = true) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(bottom = 2.dp) + ) { + Text( + text = timeFormat.format(message.timestamp), + color = timeColor, + fontSize = 11.sp, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + ) + if (message.isOutgoing) { + val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status + AnimatedMessageStatus( + status = displayStatus, + timeColor = timeColor, + timestamp = message.timestamp.time, + onRetry = onRetry, + onDelete = onDelete + ) + } + } + } + } + } + } + } +} + +/** Animated message status indicator */ +@Composable +fun AnimatedMessageStatus( + status: MessageStatus, + timeColor: Color, + timestamp: Long = 0L, + onRetry: () -> Unit = {}, + onDelete: () -> Unit = {} +) { + 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" + ) + + 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" + ) + + var showErrorMenu by remember { mutableStateOf(false) } + + Box { + Crossfade( + targetState = effectiveStatus, + animationSpec = tween(durationMillis = 200), + label = "statusIcon" + ) { currentStatus -> + val iconSize = with(LocalDensity.current) { 14.sp.toDp() } + + 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(iconSize) + .scale(scale) + .then( + if (currentStatus == MessageStatus.ERROR) { + Modifier.clickable { showErrorMenu = true } + } else Modifier + ) + ) + } + + 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 inside message */ +@Composable +fun ReplyBubble( + replyData: ReplyData, + isOutgoing: Boolean, + isDarkTheme: Boolean, + onClick: () -> Unit = {} +) { + 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 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 + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) + .background(backgroundColor) + ) { + Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp) + ) { + Text( + text = if (replyData.isForwarded) "Forwarded message" else replyData.senderName, + color = nameColor, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = replyData.text.ifEmpty { "..." }, + color = replyTextColor, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +/** Message skeleton loader with shimmer animation */ +@Composable +fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) { + val skeletonColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE0E0E0) + + 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(modifier = modifier) { + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + SkeletonBubble(true, 0.45f, skeletonColor, shimmerAlpha) + SkeletonBubble(false, 0.55f, skeletonColor, shimmerAlpha) + SkeletonBubble(true, 0.35f, skeletonColor, shimmerAlpha) + SkeletonBubble(false, 0.50f, skeletonColor, shimmerAlpha) + SkeletonBubble(true, 0.60f, skeletonColor, shimmerAlpha) + SkeletonBubble(false, 0.40f, skeletonColor, shimmerAlpha) + } + } +} + +@Composable +private fun SkeletonBubble( + isOutgoing: Boolean, + widthFraction: Float, + 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 + ) + ) + .background(bubbleColor.copy(alpha = alpha)) + .padding(horizontal = 14.dp, vertical = 10.dp) + ) + } +} + +/** Telegram-style kebab menu */ +@Composable +fun KebabMenu( + expanded: Boolean, + onDismiss: () -> Unit, + isDarkTheme: Boolean, + isSavedMessages: Boolean, + isBlocked: Boolean, + onBlockClick: () -> Unit, + onUnblockClick: () -> Unit, + onDeleteClick: () -> Unit +) { + 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 + ) + ) { + 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 = if (isDarkTheme) Color.White else Color.Black + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .height(0.5.dp) + .background(dividerColor) + ) + } + + KebabMenuItem( + icon = Icons.Default.Delete, + text = "Delete Chat", + onClick = onDeleteClick, + tintColor = Color(0xFFFF3B30), + textColor = Color(0xFFFF3B30) + ) + } +} + +@Composable +private fun KebabMenuItem( + 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) + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt new file mode 100644 index 0000000..110deba --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -0,0 +1,459 @@ +package com.rosetta.messenger.ui.chats.input + +import android.content.Context +import android.view.inputmethod.InputMethodManager +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition +import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator +import com.rosetta.messenger.ui.components.AppleEmojiTextField +import com.rosetta.messenger.ui.components.OptimizedEmojiPicker +import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import com.rosetta.messenger.ui.chats.models.* +import com.rosetta.messenger.ui.chats.components.* +import com.rosetta.messenger.ui.chats.utils.* +import com.rosetta.messenger.ui.chats.ChatViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Message input bar and related components + * Extracted from ChatDetailScreen.kt for better organization + * + * Telegram UX rules: + * 1. Input always linked to keyboard (imePadding) + * 2. Last message always visible + * 3. No layout jumps + * 4. After sending: input clears, keyboard stays open + * 5. Input grows upward for multi-line (up to 6 lines) + */ + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun MessageInputBar( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + isDarkTheme: Boolean, + backgroundColor: Color, + textColor: Color, + placeholderColor: Color, + secondaryTextColor: Color, + replyMessages: List = emptyList(), + isForwardMode: Boolean = false, + onCloseReply: () -> Unit = {}, + chatTitle: String = "", + isBlocked: Boolean = false, + showEmojiPicker: Boolean = false, + onToggleEmojiPicker: (Boolean) -> Unit = {}, + focusRequester: FocusRequester? = null, + coordinator: KeyboardTransitionCoordinator, + displayReplyMessages: List = emptyList(), + onReplyClick: (String) -> Unit = {} +) { + val hasReply = replyMessages.isNotEmpty() + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + val context = LocalContext.current + val view = LocalView.current + val density = LocalDensity.current + + var editTextView by remember { mutableStateOf(null) } + + // Auto-focus when reply panel opens + LaunchedEffect(hasReply, editTextView) { + if (hasReply) { + delay(50) + editTextView?.let { editText -> + if (!showEmojiPicker) { + editText.requestFocus() + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) + } + } + } + } + + val imeInsets = WindowInsets.ime + var isKeyboardVisible by remember { mutableStateOf(false) } + var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) } + var lastToggleTime by remember { mutableLongStateOf(0L) } + val toggleCooldownMs = 500L + var isKeyboardAnimating by remember { mutableStateOf(false) } + var lastKeyboardHeightChange by remember { mutableLongStateOf(0L) } + val keyboardAnimationStabilizeMs = 250L + + // Update coordinator through snapshotFlow (no recomposition) + 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 + } + if (now - lastKeyboardHeightChange > keyboardAnimationStabilizeMs && isKeyboardAnimating) { + isKeyboardAnimating = false + } + + isKeyboardVisible = currentImeHeight > 50.dp + coordinator.updateKeyboardHeight(currentImeHeight) + if (currentImeHeight > 100.dp) { + coordinator.syncHeights() + lastStableKeyboardHeight = currentImeHeight + } + } + } + + // Load saved keyboard height + LaunchedEffect(Unit) { + com.rosetta.messenger.ui.components.KeyboardHeightProvider.getSavedKeyboardHeight(context) + } + + // Save keyboard height when stable + LaunchedEffect(isKeyboardVisible, showEmojiPicker) { + if (isKeyboardVisible && !showEmojiPicker) { + delay(350) + if (isKeyboardVisible && !showEmojiPicker && lastStableKeyboardHeight > 300.dp) { + val heightPx = with(density) { lastStableKeyboardHeight.toPx().toInt() } + com.rosetta.messenger.ui.components.KeyboardHeightProvider.saveKeyboardHeight(context, heightPx) + } + } + } + + val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply } + var isSending by remember { mutableStateOf(false) } + + // Close keyboard when user is blocked + LaunchedEffect(isBlocked) { + if (isBlocked) { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus(force = true) + } + } + + fun hideKeyboardCompletely() { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus(force = true) + } + + fun toggleEmojiPicker() { + val currentTime = System.currentTimeMillis() + val timeSinceLastToggle = currentTime - lastToggleTime + + if (timeSinceLastToggle < toggleCooldownMs) { + return + } + + lastToggleTime = currentTime + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + + if (coordinator.isEmojiVisible) { + // EMOJI → KEYBOARD + coordinator.requestShowKeyboard( + showKeyboard = { + 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) } + ) + } + } + + fun handleSend() { + if (value.isNotBlank() || hasReply) { + isSending = true + onSend() + scope.launch { + delay(150) + isSending = false + } + } + } + + Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) { + if (isBlocked) { + // BLOCKED CHAT FOOTER + Column(modifier = Modifier.fillMaxWidth().background(backgroundColor)) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) Color.White.copy(alpha = 0.1f) + else Color.Black.copy(alpha = 0.08f) + ) + ) + + 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 { + Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) Color.White.copy(alpha = 0.1f) + else Color.Black.copy(alpha = 0.08f) + ) + ) + + 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(200, easing = FastOutSlowInEasing)) + + expandVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + expandFrom = Alignment.Bottom + ), + exit = fadeOut(animationSpec = tween(150, easing = FastOutLinearInEasing)) + + shrinkVertically( + animationSpec = tween(150, easing = FastOutLinearInEasing), + shrinkTowards = Alignment.Bottom + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + 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, androidx.compose.foundation.shape.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( + modifier = Modifier + .size(32.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + 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 + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom + ) { + 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)) + + 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 + ) { + 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 -> editTextView = view }, + onFocusChanged = { hasFocus -> + if (hasFocus && showEmojiPicker) { + onToggleEmojiPicker(false) + } + } + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + 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)) + + 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) + ) + } + } + } + } + } + } + + // EMOJI PICKER + if (!isBlocked) { + AnimatedKeyboardTransition( + coordinator = coordinator, + showEmojiPicker = showEmojiPicker + ) { + OptimizedEmojiPicker( + isVisible = true, + isDarkTheme = isDarkTheme, + onEmojiSelected = { emoji -> onValueChange(value + emoji) }, + onClose = { toggleEmojiPicker() }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt new file mode 100644 index 0000000..26d2940 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt @@ -0,0 +1,64 @@ +package com.rosetta.messenger.ui.chats.models + +import androidx.compose.runtime.Stable +import com.rosetta.messenger.data.Message +import com.rosetta.messenger.network.DeliveryStatus +import com.rosetta.messenger.network.SearchUser +import java.util.* + +/** + * Models and data classes for Chat Detail Screen + * Extracted from ChatDetailScreen.kt for better organization + */ + +/** Stable wrapper for SearchUser to optimize recompositions */ +@Stable +class StableSearchUser(val user: SearchUser) + +/** Reply/Forward data inside a message */ +data class ReplyData( + val messageId: String, + val senderName: String, + val text: String, + val isFromMe: Boolean, + val isForwarded: Boolean = false +) + +/** Legacy message model (for compatibility) */ +data class ChatMessage( + val id: String, + val text: String, + val isOutgoing: Boolean, + val timestamp: Date, + val status: MessageStatus = MessageStatus.SENT, + val showDateHeader: Boolean = false, + val replyData: ReplyData? = null +) + +/** Message delivery and read status */ +enum class MessageStatus { + SENDING, + SENT, + DELIVERED, + READ, + ERROR +} + +/** Avatar colors for consistent user identification */ +data class AvatarColors( + val backgroundColor: androidx.compose.ui.graphics.Color, + val textColor: androidx.compose.ui.graphics.Color +) + +// Extension function to convert Message to ChatMessage +fun Message.toChatMessage() = ChatMessage( + id = messageId, + text = content, + isOutgoing = isFromMe, + timestamp = Date(timestamp), + status = when (deliveryStatus) { + DeliveryStatus.WAITING -> MessageStatus.SENDING + DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED + DeliveryStatus.ERROR -> MessageStatus.SENT + } +) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/utils/ChatDetailUtils.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/utils/ChatDetailUtils.kt new file mode 100644 index 0000000..9a4cb13 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/utils/ChatDetailUtils.kt @@ -0,0 +1,118 @@ +package com.rosetta.messenger.ui.chats.utils + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.rosetta.messenger.ui.chats.models.* +import java.text.SimpleDateFormat +import java.util.* + +/** + * Utility functions and constants for Chat Detail Screen + * Extracted from ChatDetailScreen.kt for better organization + */ + +// Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910) +val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f) + +// Message delivery timeout constant (80 seconds, matching archive) +private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L + +/** Check if message can still be delivered (timeout not exceeded) */ +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 +} + +/** Get date text (today, yesterday or full date) */ +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) } + + 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)) + } +} + +/** Get user initials from name */ +fun getInitials(name: String): String { + return name.trim() + .split(Regex("\\s+")) + .filter { it.isNotEmpty() } + .let { words -> + when { + words.isEmpty() -> "??" + words.size == 1 -> words[0].take(2).uppercase() + else -> "${words[0].first()}${words[1].first()}".uppercase() + } + } +} + +/** Get consistent avatar colors based on name/key */ +fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors { + val colors = listOf( + Pair(Color(0xFFFF6B6B), Color.White), + Pair(Color(0xFF4ECDC4), Color.White), + Pair(Color(0xFF45B7D1), Color.White), + Pair(Color(0xFFF7B731), Color.White), + Pair(Color(0xFF5F27CD), Color.White), + Pair(Color(0xFF00D2D3), Color.White), + Pair(Color(0xFFFF9FF3), Color.White), + Pair(Color(0xFF54A0FF), Color.White) + ) + val index = name.hashCode().let { if (it < 0) -it else it } % colors.size + val (bg, text) = colors[index] + return AvatarColors(backgroundColor = bg, textColor = text) +} + +/** Telegram Send Icon (horizontal plane) - custom SVG icon */ +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 + ) { + 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 + ) { + moveTo(6f, 12f) + horizontalLineToRelative(16f) + } + }.build()