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 b300184..6c2bece 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 @@ -3,17 +3,20 @@ package com.rosetta.messenger.ui.chats import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.* +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -22,119 +25,98 @@ 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.focus.onFocusChanged +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.graphicsLayer +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.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontFamily 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 androidx.compose.ui.draw.blur -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.ui.unit.Dp import androidx.lifecycle.viewmodel.compose.viewModel import com.rosetta.messenger.data.Message -import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.network.DeliveryStatus -import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser -import com.rosetta.messenger.ui.onboarding.PrimaryBlue -import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel -import com.rosetta.messenger.ui.components.AppleEmojiTextField import com.rosetta.messenger.ui.components.AppleEmojiText -import androidx.compose.ui.text.font.FontFamily -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import com.rosetta.messenger.ui.components.AppleEmojiTextField +import com.rosetta.messenger.ui.components.VerifiedBadge +import com.rosetta.messenger.ui.onboarding.PrimaryBlue import java.text.SimpleDateFormat import java.util.* -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Outline -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.draw.shadow -import androidx.compose.animation.core.CubicBezierEasing -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.geometry.Offset +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch // Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910) val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f) -/** - * Модель сообщения (Legacy - для совместимости) - */ +/** Модель сообщения (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 id: String, + val text: String, + val isOutgoing: Boolean, + val timestamp: Date, + val status: MessageStatus = MessageStatus.SENT, + val showDateHeader: Boolean = false // Показывать ли разделитель даты ) enum class MessageStatus { - SENDING, SENT, DELIVERED, READ + SENDING, + SENT, + DELIVERED, + READ } -/** - * Получить текст даты (today, yesterday или полная дата) - */ +/** Получить текст даты (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.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" - + 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 - } -) +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) @Composable fun ChatDetailScreen( - user: SearchUser, - currentUserPublicKey: String, - currentUserPrivateKey: String, - isDarkTheme: Boolean, - onBack: () -> Unit, - onUserProfileClick: () -> Unit = {}, - viewModel: ChatViewModel = viewModel() + user: SearchUser, + currentUserPublicKey: String, + currentUserPrivateKey: String, + isDarkTheme: Boolean, + onBack: () -> Unit, + onUserProfileClick: () -> Unit = {}, + viewModel: ChatViewModel = viewModel() ) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -142,32 +124,32 @@ fun ChatDetailScreen( val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0) - + // � Fade-in анимация для всего экрана var isVisible by remember { mutableStateOf(false) } - val screenAlpha by animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = tween(durationMillis = 250, easing = TelegramEasing), - label = "screenFade" - ) - - LaunchedEffect(Unit) { - isVisible = true - } - + val screenAlpha by + animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), + label = "screenFade" + ) + + LaunchedEffect(Unit) { isVisible = true } + val listState = rememberLazyListState() val scope = rememberCoroutineScope() - + // Telegram-style scroll tracking var wasManualScroll by remember { mutableStateOf(false) } var isAtBottom by remember { mutableStateOf(true) } - + // Track if user is at bottom of list LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { - val isAtTop = listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + val isAtTop = + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 isAtBottom = isAtTop } - + // 🔥 Быстрое закрытие с fade-out анимацией val hideKeyboardAndBack: () -> Unit = { // Мгновенно убираем фокус и клавиатуру @@ -182,52 +164,57 @@ fun ChatDetailScreen( } Unit } - + // Определяем это Saved Messages или обычный чат val isSavedMessages = user.publicKey == currentUserPublicKey - val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) } - + val chatTitle = + if (isSavedMessages) "Saved Messages" + else user.title.ifEmpty { user.publicKey.take(10) } + // Состояние показа логов var showLogs by remember { mutableStateOf(false) } - val debugLogs by ProtocolManager.debugLogs.collectAsState() - + // val debugLogs by ProtocolManager.debugLogs.collectAsState() + val debugLogs = remember { emptyList() } + // Подключаем к ViewModel val messages by viewModel.messages.collectAsState() val inputText by viewModel.inputText.collectAsState() val isTyping by viewModel.opponentTyping.collectAsState() val isOnline by viewModel.opponentOnline.collectAsState() - + // 🔥 Добавляем информацию о датах к сообщениям - val messagesWithDates = remember(messages) { - val result = mutableListOf>() // message, showDateHeader - var lastDateString = "" - - // Сортируем по времени (старые -> новые) - val sortedMessages = messages.sortedBy { it.timestamp.time } - - for (message in sortedMessages) { - val dateString = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(message.timestamp) - val showDate = dateString != lastDateString - result.add(message to showDate) - lastDateString = dateString - } - - result - } - + val messagesWithDates = + remember(messages) { + val result = mutableListOf>() // message, showDateHeader + var lastDateString = "" + + // Сортируем по времени (старые -> новые) + val sortedMessages = messages.sortedBy { it.timestamp.time } + + for (message in sortedMessages) { + val dateString = + SimpleDateFormat("yyyyMMdd", Locale.getDefault()) + .format(message.timestamp) + val showDate = dateString != lastDateString + result.add(message to showDate) + lastDateString = dateString + } + + result + } + // Динамический subtitle: typing > online > offline - val chatSubtitle = when { - isSavedMessages -> "Notes" - isTyping -> "typing..." - isOnline -> "online" - else -> "offline" - } - + val chatSubtitle = + when { + isSavedMessages -> "Notes" + isTyping -> "typing..." + isOnline -> "online" + else -> "offline" + } + // 🔥 Обработка системной кнопки назад - BackHandler { - hideKeyboardAndBack() - } - + BackHandler { hideKeyboardAndBack() } + // 🔥 Cleanup при выходе из экрана DisposableEffect(Unit) { onDispose { @@ -235,20 +222,20 @@ fun ChatDetailScreen( keyboardController?.hide() } } - + // Инициализируем ViewModel с ключами и открываем диалог LaunchedEffect(user.publicKey) { viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey) viewModel.openDialog(user.publicKey, user.title, user.username) } - + // Отмечаем сообщения как прочитанные когда они видны LaunchedEffect(messages) { if (messages.isNotEmpty()) { viewModel.markVisibleMessagesAsRead() } } - + // Telegram-style: Прокрутка при новых сообщениях только если пользователь в низу LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { @@ -258,485 +245,473 @@ fun ChatDetailScreen( } } } - + // Аватар - val avatarColors = getAvatarColor( - if (isSavedMessages) "SavedMessages" else user.title.ifEmpty { user.publicKey }, - isDarkTheme - ) - + val avatarColors = + getAvatarColor( + if (isSavedMessages) "SavedMessages" else user.title.ifEmpty { user.publicKey }, + isDarkTheme + ) + // 🚀 Весь контент с fade-in анимацией - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = screenAlpha } - ) { + Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = screenAlpha }) { // Telegram-style solid header background (без blur) - val headerBackground = if (isDarkTheme) - Color(0xFF212121) - else - Color(0xFFFFFFFF) - + val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) + Scaffold( - topBar = { - // Telegram-style TopAppBar - solid background без blur - Box( - modifier = Modifier - .fillMaxWidth() - .background(headerBackground) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .height(56.dp) - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Кнопка назад - IconButton(onClick = hideKeyboardAndBack) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back", - tint = textColor - ) - } - - // Аватар - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(if (isSavedMessages) PrimaryBlue else avatarColors.backgroundColor) - .clickable { - keyboardController?.hide() - focusManager.clearFocus() - onUserProfileClick() - }, - contentAlignment = Alignment.Center - ) { - if (isSavedMessages) { - Icon( - Icons.Default.Bookmark, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(20.dp) - ) - } else { - Text( - text = if (user.title.isNotEmpty()) getInitials(user.title) else user.publicKey.take(2).uppercase(), - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = avatarColors.textColor - ) - } - } - - Spacer(modifier = Modifier.width(12.dp)) - - // Информация о пользователе - Column( - modifier = Modifier - .weight(1f) - .clickable { - keyboardController?.hide() - focusManager.clearFocus() - onUserProfileClick() - } - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = chatTitle, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (!isSavedMessages && user.verified > 0) { - Spacer(modifier = Modifier.width(4.dp)) - VerifiedBadge(verified = user.verified, size = 16) - } - } - Text( - text = chatSubtitle, - fontSize = 13.sp, - color = when { - isSavedMessages -> secondaryTextColor - isTyping -> PrimaryBlue // Синий когда печатает - isOnline -> Color(0xFF38B24D) // Зелёный когда онлайн - else -> secondaryTextColor // Серый для offline - }, - maxLines = 1 - ) - } - - // Кнопки действий - if (!isSavedMessages) { - IconButton(onClick = { /* TODO: Voice call */ }) { - Icon( - Icons.Default.Call, - contentDescription = "Call", - tint = textColor - ) - } - } - - // Кнопка логов (для отладки) - IconButton(onClick = { - keyboardController?.hide() - focusManager.clearFocus() - showLogs = true - }) { - Icon( - Icons.Default.BugReport, - contentDescription = "Logs", - tint = if (debugLogs.isNotEmpty()) PrimaryBlue else textColor - ) - } - - IconButton(onClick = { - keyboardController?.hide() - focusManager.clearFocus() - /* TODO: More options */ - }) { - Icon( - Icons.Default.MoreVert, - contentDescription = "More", - tint = textColor - ) - } - } - // Нижняя линия для разделения - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(0.5.dp) - .background( - if (isDarkTheme) Color.White.copy(alpha = 0.1f) - else Color.Black.copy(alpha = 0.08f) - ) - ) - } - }, - containerColor = Color.Transparent - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - // Список сообщений - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - ) { - if (messages.isEmpty()) { - // Пустое состояние - Column( - modifier = Modifier - .fillMaxSize() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - if (isSavedMessages) Icons.Default.Bookmark else Icons.Default.Chat, - contentDescription = null, - tint = secondaryTextColor.copy(alpha = 0.5f), - modifier = Modifier.size(64.dp) - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = if (isSavedMessages) - "Save messages here for quick access" - else - "No messages yet", - fontSize = 16.sp, - color = secondaryTextColor, - fontWeight = FontWeight.Medium - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = if (isSavedMessages) - "Forward messages here or send notes to yourself" - else - "Send a message to start the conversation", - fontSize = 14.sp, - color = secondaryTextColor.copy(alpha = 0.7f) - ) - } - } else { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .nestedScroll(remember { - object : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource - ): Offset { - // Отслеживаем ручную прокрутку пользователем - if (source == NestedScrollSource.Drag) { - wasManualScroll = true - } - return Offset.Zero - } - } - }), - // Добавляем padding сверху и снизу для скролла под glass header/input - contentPadding = PaddingValues( - start = 8.dp, - end = 8.dp, - top = 8.dp, - bottom = 8.dp - ), - reverseLayout = true - ) { - // Для inverted FlatList: идём от новых к старым - val reversedMessages = messagesWithDates.reversed() - itemsIndexed(reversedMessages, key = { _, item -> item.first.id }) { index, (message, showDate) -> - // В inverted списке дата показывается ПЕРЕД сообщением (визуально ПОСЛЕ) - Column { - MessageBubble( - message = message, - isDarkTheme = isDarkTheme, - index = index + topBar = { + // Telegram-style TopAppBar - solid background без blur + Box(modifier = Modifier.fillMaxWidth().background(headerBackground)) { + Row( + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .height(56.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Кнопка назад + IconButton(onClick = hideKeyboardAndBack) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Back", + tint = textColor ) - // Разделитель даты - if (showDate) { - DateHeader( - dateText = getDateText(message.timestamp.time), - secondaryTextColor = secondaryTextColor + } + + // Аватар + Box( + modifier = + Modifier.size(40.dp) + .clip(CircleShape) + .background( + if (isSavedMessages) PrimaryBlue + else avatarColors.backgroundColor + ) + .clickable { + keyboardController?.hide() + focusManager.clearFocus() + onUserProfileClick() + }, + contentAlignment = Alignment.Center + ) { + if (isSavedMessages) { + Icon( + Icons.Default.Bookmark, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } else { + Text( + text = + if (user.title.isNotEmpty()) + getInitials(user.title) + else user.publicKey.take(2).uppercase(), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = avatarColors.textColor ) } } + + Spacer(modifier = Modifier.width(12.dp)) + + // Информация о пользователе + Column( + modifier = + Modifier.weight(1f).clickable { + keyboardController?.hide() + focusManager.clearFocus() + onUserProfileClick() + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = chatTitle, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!isSavedMessages && user.verified > 0) { + Spacer(modifier = Modifier.width(4.dp)) + VerifiedBadge(verified = user.verified, size = 16) + } + } + Text( + text = chatSubtitle, + fontSize = 13.sp, + color = + when { + isSavedMessages -> secondaryTextColor + isTyping -> PrimaryBlue // Синий когда печатает + isOnline -> + Color( + 0xFF38B24D + ) // Зелёный когда онлайн + else -> secondaryTextColor // Серый для offline + }, + maxLines = 1 + ) + } + + // Кнопки действий + if (!isSavedMessages) { + IconButton(onClick = { /* TODO: Voice call */}) { + Icon( + Icons.Default.Call, + contentDescription = "Call", + tint = textColor + ) + } + } + + // Кнопка логов (для отладки) + IconButton( + onClick = { + keyboardController?.hide() + focusManager.clearFocus() + showLogs = true + } + ) { + Icon( + Icons.Default.BugReport, + contentDescription = "Logs", + tint = + if (debugLogs.isNotEmpty()) PrimaryBlue + else textColor + ) + } + + IconButton( + onClick = { + keyboardController?.hide() + focusManager.clearFocus() + /* TODO: More options */ + } + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = "More", + tint = textColor + ) + } + } + // Нижняя линия для разделения + Box( + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) + Color.White.copy(alpha = 0.1f) + else Color.Black.copy(alpha = 0.08f) + ) + ) + } + }, + containerColor = Color.Transparent + ) { paddingValues -> + Column(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + // Список сообщений + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (messages.isEmpty()) { + // Пустое состояние + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + if (isSavedMessages) Icons.Default.Bookmark + else Icons.Default.Chat, + contentDescription = null, + tint = secondaryTextColor.copy(alpha = 0.5f), + modifier = Modifier.size(64.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = + if (isSavedMessages) + "Save messages here for quick access" + else "No messages yet", + fontSize = 16.sp, + color = secondaryTextColor, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + if (isSavedMessages) + "Forward messages here or send notes to yourself" + else "Send a message to start the conversation", + fontSize = 14.sp, + color = secondaryTextColor.copy(alpha = 0.7f) + ) + } + } else { + LazyColumn( + state = listState, + modifier = + Modifier.fillMaxSize() + .nestedScroll( + remember { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource + ): Offset { + // Отслеживаем ручную прокрутку + // пользователем + if (source == + NestedScrollSource + .Drag + ) { + wasManualScroll = true + } + return Offset.Zero + } + } + } + ), + // Добавляем padding сверху и снизу для скролла под glass + // header/input + contentPadding = + PaddingValues( + start = 8.dp, + end = 8.dp, + top = 8.dp, + bottom = 8.dp + ), + reverseLayout = true + ) { + // Для inverted FlatList: идём от новых к старым + val reversedMessages = messagesWithDates.reversed() + itemsIndexed(reversedMessages, key = { _, item -> item.first.id }) { + index, + (message, showDate) -> + // В inverted списке дата показывается ПЕРЕД сообщением (визуально + // ПОСЛЕ) + Column { + MessageBubble( + message = message, + isDarkTheme = isDarkTheme, + index = index + ) + // Разделитель даты + if (showDate) { + DateHeader( + dateText = getDateText(message.timestamp.time), + secondaryTextColor = secondaryTextColor + ) + } + } + } } } - } - - // Telegram-style "Scroll to Bottom" кнопка - if (!isAtBottom && messages.isNotEmpty()) { - FloatingActionButton( - onClick = { - scope.launch { - wasManualScroll = false - listState.animateScrollToItem(0) - } - }, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 16.dp, bottom = 16.dp) - .size(48.dp), - containerColor = PrimaryBlue, - elevation = FloatingActionButtonDefaults.elevation(6.dp) - ) { - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = "Scroll to bottom", - tint = Color.White - ) - } - } - } - - // Поле ввода сообщения - MessageInputBar( - value = inputText, - onValueChange = { - viewModel.updateInputText(it) - // Отправляем индикатор печатания - if (it.isNotEmpty() && !isSavedMessages) { - viewModel.sendTypingIndicator() - } - }, - onSend = { - viewModel.sendMessage() - ProtocolManager.addLog("📤 Sending message...") - }, - isDarkTheme = isDarkTheme, - backgroundColor = inputBackgroundColor, - textColor = textColor, - placeholderColor = secondaryTextColor - ) - } - } - } // Закрытие Box с fade-in - - // Диалог логов - if (showLogs) { - AlertDialog( - onDismissRequest = { showLogs = false }, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text("Debug Logs", fontWeight = FontWeight.Bold) - IconButton(onClick = { ProtocolManager.clearLogs() }) { - Icon(Icons.Default.Delete, contentDescription = "Clear") - } - } - }, - text = { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 400.dp) - ) { - items(debugLogs.reversed()) { log -> - Text( - text = log, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - modifier = Modifier.padding(vertical = 2.dp) - ) - } - if (debugLogs.isEmpty()) { - item { - Text( - text = "No logs yet. Try sending a message.", - color = Color.Gray, - fontSize = 12.sp + + // Telegram-style "Scroll to Bottom" кнопка + if (!isAtBottom && messages.isNotEmpty()) { + FloatingActionButton( + onClick = { + scope.launch { + wasManualScroll = false + listState.animateScrollToItem(0) + } + }, + modifier = + Modifier.align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 16.dp) + .size(48.dp), + containerColor = PrimaryBlue, + elevation = FloatingActionButtonDefaults.elevation(6.dp) + ) { + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = "Scroll to bottom", + tint = Color.White ) } } } - }, - confirmButton = { - TextButton(onClick = { showLogs = false }) { - Text("Close") - } + + // Поле ввода сообщения + MessageInputBar( + value = inputText, + onValueChange = { + viewModel.updateInputText(it) + // Отправляем индикатор печатания + if (it.isNotEmpty() && !isSavedMessages) { + viewModel.sendTypingIndicator() + } + }, + onSend = { + viewModel.sendMessage() + // ProtocolManager.addLog("📤 Sending message...") + }, + isDarkTheme = isDarkTheme, + backgroundColor = inputBackgroundColor, + textColor = textColor, + placeholderColor = secondaryTextColor + ) } + } + } // Закрытие Box с fade-in + + // Диалог логов + if (showLogs) { + AlertDialog( + onDismissRequest = { showLogs = false }, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text("Debug Logs", fontWeight = FontWeight.Bold) + IconButton( + onClick = { + // ProtocolManager.clearLogs() + } + ) { Icon(Icons.Default.Delete, contentDescription = "Clear") } + } + }, + text = { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp)) { + items(debugLogs.reversed()) { log -> + Text( + text = log, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(vertical = 2.dp) + ) + } + if (debugLogs.isEmpty()) { + item { + Text( + text = "No logs yet. Try sending a message.", + color = Color.Gray, + fontSize = 12.sp + ) + } + } + } + }, + confirmButton = { TextButton(onClick = { showLogs = false }) { Text("Close") } } ) } } -/** - * 🚀 Анимация появления сообщения Telegram-style - */ +/** 🚀 Анимация появления сообщения 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" - ) - + + 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 - */ +/** 🚀 Пузырек сообщения Telegram-style */ @Composable -private fun MessageBubble( - message: ChatMessage, - isDarkTheme: Boolean, - index: Int = 0 -) { +private fun MessageBubble(message: ChatMessage, isDarkTheme: Boolean, index: Int = 0) { // Telegram-style enter animation val (alpha, translationY) = rememberMessageEnterAnimation(message.id) - + // Telegram colors - val bubbleColor = if (message.isOutgoing) { - PrimaryBlue - } else { - if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) - } - val textColor = if (message.isOutgoing) Color.White else { - if (isDarkTheme) Color.White else Color(0xFF000000) - } - val timeColor = if (message.isOutgoing) Color.White.copy(alpha = 0.7f) else { - if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - } - + val bubbleColor = + if (message.isOutgoing) { + PrimaryBlue + } else { + if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) + } + val textColor = + if (message.isOutgoing) Color.White + else { + if (isDarkTheme) Color.White else Color(0xFF000000) + } + val timeColor = + if (message.isOutgoing) Color.White.copy(alpha = 0.7f) + else { + if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + } + // Telegram bubble shape - простая форма с разными радиусами углов - val bubbleShape = RoundedCornerShape( - topStart = 18.dp, - topEnd = 18.dp, - bottomStart = if (message.isOutgoing) 18.dp else 4.dp, - bottomEnd = if (message.isOutgoing) 4.dp else 18.dp - ) - + val bubbleShape = + RoundedCornerShape( + topStart = 18.dp, + topEnd = 18.dp, + bottomStart = if (message.isOutgoing) 18.dp else 4.dp, + bottomEnd = if (message.isOutgoing) 4.dp else 18.dp + ) + val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } - + Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 1.dp) - .graphicsLayer { - this.alpha = alpha - this.translationY = translationY - }, - horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 1.dp) + .graphicsLayer { + this.alpha = alpha + this.translationY = translationY + }, + horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start ) { Box( - modifier = Modifier - .widthIn(max = 300.dp) - .shadow( - elevation = 1.dp, - shape = bubbleShape, - clip = false - ) - .clip(bubbleShape) - .background(bubbleColor) - .padding(horizontal = 12.dp, vertical = 7.dp) + modifier = + Modifier.widthIn(max = 300.dp) + .shadow(elevation = 1.dp, shape = bubbleShape, clip = false) + .clip(bubbleShape) + .background(bubbleColor) + .padding(horizontal = 12.dp, vertical = 7.dp) ) { Column { - AppleEmojiText( - text = message.text, - color = textColor, - fontSize = 16.sp - ) + AppleEmojiText(text = message.text, color = textColor, fontSize = 16.sp) Spacer(modifier = Modifier.height(2.dp)) Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically ) { Text( - text = timeFormat.format(message.timestamp), - color = timeColor, - fontSize = 11.sp + text = timeFormat.format(message.timestamp), + color = timeColor, + fontSize = 11.sp ) if (message.isOutgoing) { Spacer(modifier = Modifier.width(3.dp)) Icon( - when (message.status) { - MessageStatus.SENDING -> Icons.Default.Schedule - MessageStatus.SENT -> Icons.Default.Done - MessageStatus.DELIVERED -> Icons.Default.DoneAll - MessageStatus.READ -> Icons.Default.DoneAll - }, - contentDescription = null, - tint = if (message.status == MessageStatus.READ) - Color(0xFF4FC3F7) // Голубые галочки как в Telegram - else - timeColor, - modifier = Modifier.size(16.dp) + when (message.status) { + MessageStatus.SENDING -> Icons.Default.Schedule + MessageStatus.SENT -> Icons.Default.Done + MessageStatus.DELIVERED -> Icons.Default.DoneAll + MessageStatus.READ -> Icons.Default.DoneAll + }, + contentDescription = null, + tint = + if (message.status == MessageStatus.READ) + Color(0xFF4FC3F7) // Голубые галочки как в Telegram + else timeColor, + modifier = Modifier.size(16.dp) ) } } @@ -745,110 +720,119 @@ private fun MessageBubble( } } -/** - * 🚀 Разделитель даты с fade-in анимацией - */ +/** 🚀 Разделитель даты с fade-in анимацией */ @Composable -private fun DateHeader( - dateText: String, - secondaryTextColor: Color -) { +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 - } - + 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 + 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) + 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) ) } } /** - * Панель ввода сообщения 1:1 как в React Native - * Оптимизированная версия с правильным позиционированием + * Панель ввода сообщения 1:1 как в React Native Оптимизированная версия с правильным + * позиционированием */ @OptIn(ExperimentalComposeUiApi::class) @Composable private fun MessageInputBar( - value: String, - onValueChange: (String) -> Unit, - onSend: () -> Unit, - isDarkTheme: Boolean, - backgroundColor: Color, - textColor: Color, - placeholderColor: Color + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + isDarkTheme: Boolean, + backgroundColor: Color, + textColor: Color, + placeholderColor: Color ) { var showEmojiPicker by remember { mutableStateOf(false) } val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current val interactionSource = remember { MutableInteractionSource() } - - // Цвета - val circleBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.8f) else Color(0xFFF0F0F0).copy(alpha = 0.85f) - val circleBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f) - val circleIcon = if (isDarkTheme) Color.White else Color(0xFF333333) - val glassBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.9f) else Color(0xFFF0F0F0).copy(alpha = 0.92f) - val glassBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f) - val emojiIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.62f) else Color.Black.copy(alpha = 0.5f) - val panelBackground = if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.88f) else Color(0xFFF5F5F5).copy(alpha = 0.88f) - + + // Цвета - Telegram liquid glass style + val circleBackground = + if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.75f) + else Color(0xFFF0F0F0).copy(alpha = 0.85f) + val circleBorder = + if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.08f) + val circleIcon = if (isDarkTheme) Color.White.copy(alpha = 0.85f) else Color(0xFF333333) + + // Liquid glass input - темнее и с эффектом размытия + val glassBackground = + if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.88f) + else Color(0xFFF0F0F0).copy(alpha = 0.92f) + val glassBorder = + if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.08f) + val emojiIconColor = + if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.5f) + val panelBackground = + if (isDarkTheme) Color(0xFF0E0E0E).copy(alpha = 0.95f) + else Color(0xFFF5F5F5).copy(alpha = 0.95f) + // Состояние отправки val canSend = remember(value) { value.isNotBlank() } - + // Easing val backEasing = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f) val smoothEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f) - + // Анимации Send - val sendScale by animateFloatAsState( - targetValue = if (canSend) 1f else 0f, - animationSpec = tween(220, easing = backEasing), - label = "sendScale" - ) - + val sendScale by + animateFloatAsState( + targetValue = if (canSend) 1f else 0f, + animationSpec = tween(220, easing = backEasing), + label = "sendScale" + ) + // Анимации Mic - val micOpacity by animateFloatAsState( - targetValue = if (canSend) 0f else 1f, - animationSpec = tween(200, easing = smoothEasing), - label = "micOpacity" - ) - val micTranslateX by animateFloatAsState( - targetValue = if (canSend) 80f else 0f, - animationSpec = tween(250, easing = smoothEasing), - label = "micTranslateX" - ) - - // Input margin - val inputEndMargin by animateDpAsState( - targetValue = if (canSend) 0.dp else 56.dp, - animationSpec = tween(220, easing = smoothEasing), - label = "inputEndMargin" - ) - + val micOpacity by + animateFloatAsState( + targetValue = if (canSend) 0f else 1f, + animationSpec = tween(200, easing = smoothEasing), + label = "micOpacity" + ) + val micTranslateX by + animateFloatAsState( + targetValue = if (canSend) 80f else 0f, + animationSpec = tween(250, easing = smoothEasing), + label = "micTranslateX" + ) + + // Input margin - для правильного позиционирования как на скриншоте + val inputEndMargin by + animateDpAsState( + targetValue = if (canSend) 0.dp else 44.dp, + animationSpec = tween(220, easing = smoothEasing), + label = "inputEndMargin" + ) + // Функция переключения emoji picker fun toggleEmojiPicker() { if (showEmojiPicker) { @@ -860,7 +844,7 @@ private fun MessageInputBar( showEmojiPicker = true } } - + // Функция отправки fun handleSend() { if (value.isNotBlank()) { @@ -868,201 +852,209 @@ private fun MessageInputBar( onValueChange("") } } - + Column( - modifier = Modifier - .fillMaxWidth() - .then(if (!showEmojiPicker) Modifier.imePadding() else Modifier) + modifier = + Modifier.fillMaxWidth() + .then(if (!showEmojiPicker) Modifier.imePadding() else Modifier) ) { - // Telegram-style input panel - solid background без blur - val inputPanelBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) - - Box( - modifier = Modifier - .fillMaxWidth() - .background(inputPanelBackground) - ) { - // Верхняя линия для разделения - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(0.5.dp) - .background( - if (isDarkTheme) Color.White.copy(alpha = 0.12f) - else Color.Black.copy(alpha = 0.1f) - ) - ) - + // 🔥 TELEGRAM-STYLE LIQUID GLASS INPUT - точь-в-точь как в Telegram + + Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) { + // Единый liquid glass контейнер Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom + modifier = + Modifier.fillMaxWidth() + .heightIn(min = 44.dp, max = 140.dp) + .shadow( + elevation = 4.dp, + shape = RoundedCornerShape(22.dp), + clip = false, + ambientColor = Color.Black.copy(alpha = 0.2f), + spotColor = Color.Black.copy(alpha = 0.2f) + ) + .clip(RoundedCornerShape(22.dp)) + .background( + // Telegram liquid glass - более темный и глубокий + brush = + Brush.verticalGradient( + colors = + if (isDarkTheme) { + listOf( + Color(0xFF2C2C2E) + .copy( + alpha = + 0.92f + ), + Color(0xFF1C1C1E) + .copy( + alpha = + 0.96f + ) + ) + } else { + listOf( + Color(0xFFF2F2F7) + .copy( + alpha = + 0.95f + ), + Color(0xFFE5E5EA) + .copy( + alpha = + 0.98f + ) + ) + } + ) + ) + .border( + width = 0.5.dp, + brush = + Brush.verticalGradient( + colors = + if (isDarkTheme) { + listOf( + Color.White.copy( + alpha = + 0.12f + ), + Color.White.copy( + alpha = + 0.04f + ) + ) + } else { + listOf( + Color.White.copy( + alpha = 0.9f + ), + Color.Black.copy( + alpha = + 0.03f + ) + ) + } + ), + shape = RoundedCornerShape(22.dp) + ) + .padding(start = 6.dp, end = 6.dp, top = 2.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically ) { - // ATTACH BUTTON + // EMOJI BUTTON - слева внутри контейнера Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(circleBackground) - .border(1.dp, circleBorder, CircleShape) - .clickable( - interactionSource = interactionSource, - indication = null - ) { /* TODO */ }, - contentAlignment = Alignment.Center + modifier = + Modifier.size(36.dp) + .clip(CircleShape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { toggleEmojiPicker() } + ), + contentAlignment = Alignment.Center ) { Icon( - Icons.Default.AttachFile, - contentDescription = "Attach", - tint = circleIcon, - modifier = Modifier.size(22.dp) + if (showEmojiPicker) Icons.Default.Keyboard + else Icons.Default.SentimentSatisfiedAlt, + contentDescription = "Emoji", + tint = + if (showEmojiPicker) PrimaryBlue + else { + if (isDarkTheme) Color.White.copy(alpha = 0.65f) + else Color.Black.copy(alpha = 0.55f) + }, + modifier = Modifier.size(26.dp) ) } - - Spacer(modifier = Modifier.width(8.dp)) - - // GLASS INPUT + + // TEXT INPUT - растягивается Box( - modifier = Modifier - .weight(1f) - .padding(end = inputEndMargin) - .heightIn(min = 48.dp, max = 120.dp) - .clip(RoundedCornerShape(24.dp)) - .background(glassBackground) - .border(1.dp, glassBorder, RoundedCornerShape(24.dp)) - ) { - // Text input - Box( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 52.dp, top = 12.dp, bottom = 12.dp), + modifier = Modifier.weight(1f).padding(vertical = 10.dp, horizontal = 4.dp), contentAlignment = Alignment.CenterStart - ) { - AppleEmojiTextField( + ) { + AppleEmojiTextField( value = value, - onValueChange = { newValue -> - // Закрываем emoji picker при печати с клавиатуры - if (showEmojiPicker && newValue.length > value.length) { - // Не закрываем - пользователь мог выбрать emoji - } - onValueChange(newValue) - }, + onValueChange = { newValue -> onValueChange(newValue) }, textColor = textColor, - textSize = 16f, + textSize = 17f, hint = "Message", - hintColor = placeholderColor.copy(alpha = 0.6f), + hintColor = + if (isDarkTheme) Color.White.copy(alpha = 0.35f) + else Color.Black.copy(alpha = 0.35f), modifier = Modifier.fillMaxWidth() - ) - } - - // RIGHT ZONE - emoji или send + ) + } + + // ATTACH BUTTON - показывается всегда + Box( + modifier = + Modifier.size(36.dp).clip(CircleShape).clickable( + interactionSource = interactionSource, + indication = null + ) { /* TODO: Attach */}, + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Attachment, + contentDescription = "Attach", + tint = + if (isDarkTheme) Color.White.copy(alpha = 0.65f) + else Color.Black.copy(alpha = 0.55f), + modifier = Modifier.size(24.dp).graphicsLayer { rotationZ = 45f } + ) + } + + Spacer(modifier = Modifier.width(2.dp)) + + // SEND BUTTON - появляется только когда есть текст (как в Telegram) + androidx.compose.animation.AnimatedVisibility( + visible = canSend, + enter = + scaleIn( + initialScale = 0.6f, + animationSpec = tween(200, easing = backEasing) + ) + fadeIn(animationSpec = tween(150)), + exit = + scaleOut(targetScale = 0.6f, animationSpec = tween(150)) + + fadeOut(animationSpec = tween(100)) + ) { Box( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 6.dp) - .size(40.dp) - ) { - // Emoji button (показывается когда нет текста) - if (!canSend) { - Box( - modifier = Modifier - .fillMaxSize() - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = { toggleEmojiPicker() } - ), - contentAlignment = Alignment.Center - ) { - Icon( - if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, - contentDescription = "Emoji", - tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor, - modifier = Modifier.size(24.dp) - ) - } - } - - // Send button (показывается когда есть текст) - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = sendScale - scaleY = sendScale - alpha = sendScale - } - .clip(CircleShape) - .background( - brush = Brush.linearGradient( - colors = listOf(Color(0xFF007AFF), Color(0xFF5AC8FA)) - ) - ) - .clickable( - interactionSource = interactionSource, - indication = null, - enabled = canSend, - onClick = { handleSend() } - ), + modifier = + Modifier.size(36.dp) + .clip(CircleShape) + .background(PrimaryBlue) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { handleSend() } + ), contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.ArrowUpward, + ) { + // Telegram-style send icon (самолетик) + Icon( + imageVector = Icons.Default.Send, contentDescription = "Send", tint = Color.White, - modifier = Modifier.size(22.dp) - ) - } + modifier = Modifier.size(22.dp).graphicsLayer { rotationZ = -45f } + ) } } } - - // MIC BUTTON - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 14.dp, bottom = 8.dp) - .graphicsLayer { - translationX = micTranslateX - alpha = micOpacity - } - .size(48.dp) - .clip(CircleShape) - .background(circleBackground) - .border(1.dp, circleBorder, CircleShape) - .clickable( - interactionSource = interactionSource, - indication = null, - enabled = !canSend - ) { /* TODO */ }, - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.Mic, - contentDescription = "Voice", - tint = circleIcon, - modifier = Modifier.size(22.dp) - ) - } } - + // Apple Emoji Picker AnimatedVisibility( - visible = showEmojiPicker, - enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut() + visible = showEmojiPicker, + enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut() ) { AppleEmojiPickerPanel( - isDarkTheme = isDarkTheme, - onEmojiSelected = { emoji -> - onValueChange(value + emoji) - }, - onClose = { showEmojiPicker = false } + isDarkTheme = isDarkTheme, + onEmojiSelected = { emoji -> onValueChange(value + emoji) }, + onClose = { showEmojiPicker = false } ) } - + if (!showEmojiPicker) { Spacer(modifier = Modifier.navigationBarsPadding()) }