package chats.presentation.chat_detail import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.runtime.* import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.IntOffset import kotlin.math.roundToInt import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.zIndex import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.unit.dp import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardActions import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import chats.presentation.components.MediaPicker import chats.presentation.components.MessageBubble import core.presentation.components.AppAvatar import core.presentation.components.AppMediaLightbox import core.utils.VoiceRecorder import core.utils.copyUriToFile import java.io.File import ru.knot.messager.R import kotlinx.coroutines.launch import android.Manifest import android.content.pm.PackageManager import androidx.core.content.ContextCompat import androidx.compose.ui.draw.clip import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.draw.shadow import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.ui.unit.sp import coil.compose.rememberAsyncImagePainter import chats.presentation.components.SwipeableMessageItem import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ChatDetailScreen( chatId: String, chatName: String, viewModel: ChatDetailViewModel, onBack: () -> Unit ) { DisposableEffect(Unit) { onDispose { viewModel.clearChatId() } } val state by viewModel.state.collectAsState() val context = LocalContext.current val listState = rememberLazyListState() val scope = rememberCoroutineScope() val voiceRecorder = remember { VoiceRecorder(context) } var isEmojiPickerVisible by remember { mutableStateOf(false) } var isRecording by remember { mutableStateOf(false) } var recordingOffset by remember { mutableFloatStateOf(0f) } var isRecordingCanceled by remember { mutableStateOf(false) } var currentRecordingFile by remember { mutableStateOf(null) } val micPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted -> if (isGranted) { val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3") voiceRecorder.startRecording(file) isRecording = true } } val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current var autoPlayingMessageId by remember { mutableStateOf(null) } var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) } var selectedMediaList by remember { mutableStateOf?>(null) } var initialMediaIndex by remember { mutableIntStateOf(0) } val focusRequester = remember { FocusRequester() } // Автоматический фокус при ответе LaunchedEffect(state.replyingMessage) { if (state.replyingMessage != null) { focusRequester.requestFocus() } } val playNextVoiceMessage = { currentId: String, speed: Float -> val currentIndex = state.messages.indexOfFirst { it.id == currentId } if (currentIndex != -1 && currentIndex < state.messages.size - 1) { val nextVoiceIndexInSublist = state.messages.subList(currentIndex + 1, state.messages.size) .indexOfFirst { it.mediaType == chats.domain.model.MediaType.AUDIO && it.content.isNullOrEmpty() } if (nextVoiceIndexInSublist != -1) { val actualNextIndex = currentIndex + 1 + nextVoiceIndexInSublist autoPlayingMessageId = state.messages[actualNextIndex].id currentPlaybackSpeed = speed // Прокручиваем к следующему сообщению, иначе оно не распарсится LazyColumn scope.launch { listState.animateScrollToItem(actualNextIndex) } } else { autoPlayingMessageId = null } } else { autoPlayingMessageId = null } } // Пикер галереи (Мультивыбор) val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia() ) { uris -> uris.forEach { viewModel.addPendingAttachment(it, context) } } // Загрузка данных чата при входе LaunchedEffect(chatId) { viewModel.setChatId(chatId) } val listItems = remember(state.messages) { val items = mutableListOf() if (state.messages.isEmpty()) return@remember items // Группируем от старых к новым (для normal layout) val reversedMessages = state.messages.reversed() var i = 0 while (i < reversedMessages.size) { val isoDate = reversedMessages[i].createdAt val localDate = viewModel.getLocalDateString(isoDate) val dayMessages = mutableListOf() while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) { dayMessages.add(reversedMessages[i]) i++ } // Добавляем заголовок даты items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate))) // Добавляем сообщения dayMessages.forEach { msg -> items.add(MessageListItem.MessageItem(msg)) } } items } // Выносим allChatMedia наружу - до LazyColumn val allChatMedia = remember(state.messages) { state.messages.flatMap { msg -> msg.media.map { it to msg.id } } } // Отслеживаем текущую дату для плавающего заголовка val floatingDate by remember { derivedStateOf { val layoutInfo = listState.layoutInfo val visibleItems = layoutInfo.visibleItemsInfo if (visibleItems.isEmpty()) return@derivedStateOf null // Находим первый видимый элемент val firstVisibleItemIndex = visibleItems.first().index if (firstVisibleItemIndex > 0 && firstVisibleItemIndex < listItems.size) { val item = listItems[firstVisibleItemIndex] if (item is MessageListItem.MessageItem) { viewModel.formatDateHeader(item.message.createdAt) } else if (item is MessageListItem.DateHeader) { item.date } else null } else null } } // Показывать ли кнопку "вниз" val showScrollToBottom by remember { derivedStateOf { val layoutInfo = listState.layoutInfo val visibleItems = layoutInfo.visibleItemsInfo val totalCount = layoutInfo.totalItemsCount if (totalCount == 0) return@derivedStateOf false // Проверяем, видим ли мы последний элемент val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: 0 lastVisibleIndex < totalCount - 1 } } // Авто-скролл к новым сообщениям - только если мы уже внизу val lastMessageId = state.messages.lastOrNull()?.id LaunchedEffect(lastMessageId) { if (lastMessageId != null && listItems.isNotEmpty()) { val isAtBottom = listState.layoutInfo.run { val visibleItems = visibleItemsInfo val totalCount = totalItemsCount if (totalCount == 0 || visibleItems.isEmpty()) return@run true visibleItems.last().index >= totalCount - 2 } if (isAtBottom) { listState.animateScrollToItem(listItems.size - 1) } } } // Автоматическая прокрутка при инициализации LaunchedEffect(listItems.size) { if (listItems.isNotEmpty()) { listState.scrollToItem(listItems.size - 1) viewModel.onInitialScrollDone() viewModel.markAsRead() } } // Подгрузка истории при прокрутке вверх LaunchedEffect(listState) { snapshotFlow { listState.layoutInfo } .collect { layoutInfo -> val visibleItems = layoutInfo.visibleItemsInfo val totalCount = layoutInfo.totalItemsCount if (totalCount == 0 || visibleItems.isEmpty()) return@collect val firstVisibleIndex = visibleItems.first().index if (firstVisibleIndex < 5 && !state.isLoadingMore) { android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $firstVisibleIndex") viewModel.loadMoreMessages() } } } val unreadCount = remember(state.messages) { state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() } } // Авто-прочитка при нахождении внизу списка LaunchedEffect(listState.isScrollInProgress) { if (!listState.isScrollInProgress) { val layoutInfo = listState.layoutInfo val visibleItems = layoutInfo.visibleItemsInfo val totalCount = layoutInfo.totalItemsCount if (totalCount > 0 && visibleItems.isNotEmpty()) { val lastVisibleIndex = visibleItems.last().index if (lastVisibleIndex >= totalCount - 2) { viewModel.markAsRead() } } } } Scaffold( topBar = { if (state.selectedMessageIds.isNotEmpty()) { TopAppBar( title = { Text("${state.selectedMessageIds.size}") }, navigationIcon = { IconButton(onClick = { viewModel.clearSelection() }) { Icon(Icons.Default.Close, contentDescription = "Cancel") } }, actions = { IconButton(onClick = { viewModel.onForwardSelectedMessages() }) { Icon(Icons.Default.Forward, contentDescription = "Forward") } IconButton(onClick = { // TODO: Bulk Delete }) { Icon(Icons.Default.Delete, contentDescription = "Delete") } } ) } else { TopAppBar( title = { Row(verticalAlignment = Alignment.CenterVertically) { AppAvatar( url = state.chatAvatar, name = state.chatName ?: chatName, size = 36.dp, modifier = Modifier.padding(end = 8.dp) ) Column { Text(state.chatName ?: chatName, style = MaterialTheme.typography.titleMedium) if (state.isTyping) { Text( stringResource(R.string.typing), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary ) } } } }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back)) } }, actions = { if (state.canCall) { IconButton(onClick = { /* Вызов (WebRTC) */ }) { Icon(Icons.Default.Call, contentDescription = stringResource(R.string.call)) } IconButton(onClick = { /* Видеозвонок (WebRTC) */ }) { Icon(Icons.Default.VideoCall, contentDescription = stringResource(R.string.video_call)) } } } ) } } ) { paddingValues -> Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) .imePadding() ) { // Список сообщений Box( modifier = Modifier .weight(1f) .background(MaterialTheme.colorScheme.background) ) { LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items( items = listItems, key = { item -> when(item) { is MessageListItem.MessageItem -> "msg_${item.message.id}" is MessageListItem.DateHeader -> "date_${item.date}" MessageListItem.UnreadSeparator -> "unread_separator" } }, contentType = { item -> when(item) { is MessageListItem.MessageItem -> "message" is MessageListItem.DateHeader -> "date_header" MessageListItem.UnreadSeparator -> "unread_separator" } } ) { item -> when(item) { is MessageListItem.MessageItem -> { SwipeableMessageItem( onReply = { viewModel.onReply(item.message) } ) { MessageBubble( message = item.message, isCurrentUser = item.message.senderId == viewModel.getCurrentUserId(), autoPlay = item.message.id == autoPlayingMessageId, initialPlaybackSpeed = if (item.message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f, onVoiceFinished = { speed -> playNextVoiceMessage(item.message.id, speed) }, onReactionClick = { emoji -> viewModel.addReaction(item.message.id, emoji) }, onReply = { msg -> viewModel.onReply(msg) }, onReplyClick = { reply -> val index = listItems.indexOfFirst { it is MessageListItem.MessageItem && it.message.id == reply.id } if (index != -1) { scope.launch { listState.animateScrollToItem(index) } } }, onSelect = { viewModel.toggleSelection(it) }, onForward = { viewModel.onForward(it) }, onPin = { viewModel.onPin(it) }, onEdit = { viewModel.onEdit(it) }, onDelete = { msg, forEveryone -> viewModel.deleteMessage(msg, forEveryone) }, isSelected = state.selectedMessageIds.contains(item.message.id), isSelectionMode = state.selectedMessageIds.isNotEmpty(), onMediaClick = { clickedMedia -> val isMedia = clickedMedia.type.startsWith("image") || clickedMedia.type.startsWith("video") || clickedMedia.type.contains("gif") if (isMedia) { val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url } selectedMediaList = allChatMedia.map { it.first } initialMediaIndex = if (initialIndex != -1) initialIndex else 0 } else { try { val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url)) context.startActivity(intent) } catch (e: Exception) { } } } ) } } is MessageListItem.DateHeader -> { Box( modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), contentAlignment = Alignment.Center ) { Surface( color = Color.Black.copy(alpha = 0.2f), shape = RoundedCornerShape(12.dp) ) { Text( text = item.date, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), style = MaterialTheme.typography.labelMedium, color = Color.White ) } } } MessageListItem.UnreadSeparator -> { Box( modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), contentAlignment = Alignment.Center ) { Row(verticalAlignment = Alignment.CenterVertically) { Box(Modifier.weight(1f).height(0.5.dp).background(Color.Gray.copy(alpha = 0.3f))) Text( text = "НЕПРОЧИТАННЫЕ СООБЩЕНИЯ", modifier = Modifier.padding(horizontal = 12.dp), style = MaterialTheme.typography.labelSmall, color = Color.Gray.copy(alpha = 0.6f) ) Box(Modifier.weight(1f).height(0.5.dp).background(Color.Gray.copy(alpha = 0.3f))) } } } } } } if (state.isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } // Плавающий заголовок даты (на самом верху списка под топбаром) floatingDate?.let { date -> Box( modifier = Modifier .fillMaxWidth() .padding(top = 8.dp) .align(Alignment.TopCenter) .zIndex(1f), // Гарантируем видимость поверх всего contentAlignment = Alignment.TopCenter ) { Surface( color = Color.DarkGray.copy(alpha = 0.9f), shape = CircleShape, shadowElevation = 4.dp ) { Text( text = date, modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), color = Color.White ) } } } // Кнопка прокрутки вниз с бейджем if (showScrollToBottom) { Box( modifier = Modifier .align(Alignment.BottomEnd) .padding(bottom = 16.dp, end = 16.dp) ) { Box( modifier = Modifier .size(44.dp) .shadow(8.dp, CircleShape) .clip(CircleShape) .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) .border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape) .clickable { scope.launch { listState.animateScrollToItem(listItems.size - 1) } }, contentAlignment = Alignment.Center ) { Icon( Icons.Default.KeyboardArrowDown, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(28.dp) ) } if (unreadCount > 0) { Surface( color = Color(0xFFF44336), // Красный как в вебе shape = CircleShape, modifier = Modifier .align(Alignment.TopEnd) .offset(x = 4.dp, y = (-4).dp) ) { Text( text = if (unreadCount > 99) "99+" else unreadCount.toString(), color = Color.White, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) ) } } } } } // Панель ввода if (state.selectedMessageIds.isEmpty()) { Column( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) ) { // Pending Attachments Bar if (state.pendingAttachments.isNotEmpty()) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = "Вложения (${state.pendingAttachments.size})", style = MaterialTheme.typography.labelMedium, color = Color.Gray, modifier = Modifier.weight(1f) ) // HQ Toggle moved here if (state.pendingAttachments.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp") }) { TextButton( onClick = { viewModel.toggleCompression() }, modifier = Modifier.height(30.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp) ) { Icon( imageVector = if (!state.isCompressionEnabled) Icons.Default.HighQuality else Icons.Default.Hd, contentDescription = null, modifier = Modifier.size(16.dp), tint = if (!state.isCompressionEnabled) MaterialTheme.colorScheme.primary else Color.Gray ) Spacer(Modifier.width(4.dp)) Text( text = if (!state.isCompressionEnabled) "Без сжатия" else "Сжать (HD)", fontSize = 11.sp, color = if (!state.isCompressionEnabled) MaterialTheme.colorScheme.primary else Color.Gray ) } } } Row( modifier = Modifier .fillMaxWidth() .padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically ) { androidx.compose.foundation.lazy.LazyRow( modifier = Modifier.weight(1f), contentPadding = PaddingValues(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { items(state.pendingAttachments) { file -> Box( modifier = Modifier .size(80.dp) .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant) ) { val isImage = file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") val isVideo = file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") if (isImage || isVideo) { androidx.compose.foundation.Image( painter = rememberAsyncImagePainter(file), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = androidx.compose.ui.layout.ContentScale.Crop ) if (isVideo) { Icon( Icons.Default.PlayCircle, contentDescription = null, modifier = Modifier.align(Alignment.Center).size(32.dp), tint = Color.White.copy(alpha = 0.8f) ) } } else { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Icon( Icons.Default.InsertDriveFile, contentDescription = null, modifier = Modifier.size(32.dp) ) Text( text = file.extension.uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } // Кнопка закрытия (Centered properly) Surface( onClick = { viewModel.removePendingAttachment(file) }, color = Color.Black.copy(alpha = 0.5f), shape = CircleShape, modifier = Modifier .align(Alignment.TopEnd) .padding(4.dp) .size(20.dp) ) { Icon( Icons.Default.Close, contentDescription = "Remove", tint = Color.White, modifier = Modifier.padding(4.dp) ) } } } } if (state.isUploading) { CircularProgressIndicator(modifier = Modifier.size(24.dp).padding(4.dp)) } } } // Reply Preview Bar state.replyingMessage?.let { replyMsg -> Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 4.dp) .height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .fillMaxHeight() .width(2.dp) .background(MaterialTheme.colorScheme.primary) ) Icon( imageVector = Icons.Default.Reply, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 8.dp).size(20.dp) ) Column( modifier = Modifier .weight(1f) .padding(horizontal = 8.dp, vertical = 2.dp) ) { val replyPrefix = stringResource(R.string.reply_prefix) val replySelf = stringResource(R.string.reply_self) Text( text = replyPrefix + (if (replyMsg.senderId == viewModel.getCurrentUserId()) replySelf else replyMsg.senderName), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold ) val photoText = stringResource(R.string.reply_photo) val videoText = stringResource(R.string.reply_video) val voiceText = stringResource(R.string.voice_message) val audioText = stringResource(R.string.reply_audio) val fileText = stringResource(R.string.reply_file) val gifText = stringResource(R.string.reply_gif) val mediaText = stringResource(R.string.media) val replyText = remember(replyMsg) { if (!replyMsg.content.isNullOrEmpty()) { replyMsg.content } else { when (replyMsg.mediaType) { chats.domain.model.MediaType.IMAGE -> photoText chats.domain.model.MediaType.VIDEO -> videoText chats.domain.model.MediaType.AUDIO -> if (replyMsg.media.any { it.type == "voice" }) voiceText else audioText chats.domain.model.MediaType.FILE -> fileText chats.domain.model.MediaType.GIF -> gifText else -> mediaText } } } Text( text = replyText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) } if ((replyMsg.mediaType == chats.domain.model.MediaType.IMAGE || replyMsg.mediaType == chats.domain.model.MediaType.VIDEO || replyMsg.mediaType == chats.domain.model.MediaType.GIF) && replyMsg.media.isNotEmpty()) { androidx.compose.foundation.Image( painter = rememberAsyncImagePainter(replyMsg.media.first().url), contentDescription = null, modifier = Modifier .padding(end = 4.dp) .size(36.dp) .clip(RoundedCornerShape(4.dp)), contentScale = androidx.compose.ui.layout.ContentScale.Crop ) } IconButton(onClick = { viewModel.cancelReply() }) { Icon(Icons.Default.Close, contentDescription = "Cancel", modifier = Modifier.size(20.dp), tint = Color.Gray) } } } // Edit Preview Bar state.editingMessage?.let { editMsg -> Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 4.dp) .height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .fillMaxHeight() .width(2.dp) .background(MaterialTheme.colorScheme.primary) ) Icon( imageVector = Icons.Default.Edit, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 8.dp).size(20.dp) ) Column( modifier = Modifier .weight(1f) .padding(horizontal = 8.dp, vertical = 2.dp) ) { Text( text = "Редактирование", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold ) Text( text = editMsg.content ?: "", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) } IconButton(onClick = { viewModel.cancelEdit() }) { Icon(Icons.Default.Close, contentDescription = "Cancel", modifier = Modifier.size(20.dp), tint = Color.Gray) } } } Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { // 1. Скрепка (слева) IconButton(onClick = { galleryLauncher.launch( androidx.activity.result.PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) ) }) { Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach), tint = Color.Gray) } // 2. Поле ввода + Эмодзи (в одном баббле) Box( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f)) .padding(horizontal = 4.dp, vertical = 4.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { IconButton( onClick = { isEmojiPickerVisible = !isEmojiPickerVisible if (isEmojiPickerVisible) { keyboardController?.hide() focusManager.clearFocus() } } ) { Icon( Icons.Default.EmojiEmotions, contentDescription = stringResource(R.string.emoji), tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray, modifier = Modifier.size(24.dp) ) } BasicTextField( value = state.inputText, onValueChange = { viewModel.onInputTextChanged(it) }, modifier = Modifier .weight(1f) .focusRequester(focusRequester) .onFocusChanged { if (it.isFocused) { isEmojiPickerVisible = false } }, textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary), keyboardOptions = KeyboardOptions( imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences ), maxLines = 6, decorationBox = { innerTextField -> if (state.inputText.isEmpty()) { Text( stringResource(R.string.message_placeholder), style = MaterialTheme.typography.bodyLarge, color = Color.Gray ) } innerTextField() } ) } } Spacer(modifier = Modifier.width(8.dp)) // 3. Кнопка действия (в синем квадрате) val showMic = state.inputText.isEmpty() && state.pendingAttachments.isEmpty() Surface( onClick = { if (!showMic) { viewModel.sendMessage() isEmojiPickerVisible = false } }, shape = RoundedCornerShape(16.dp), color = if (showMic) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) else MaterialTheme.colorScheme.primary, modifier = Modifier .size(48.dp) .pointerInput(Unit) { if (showMic) { detectDragGesturesAfterLongPress( onDragStart = { val hasMic = ContextCompat.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED if (hasMic) { val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3") currentRecordingFile = file voiceRecorder.startRecording(file) isRecording = true isRecordingCanceled = false recordingOffset = 0f } else { micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } }, onDrag = { change, dragAmount -> change.consume() recordingOffset += dragAmount.x if (recordingOffset < -200f) { isRecordingCanceled = true } }, onDragEnd = { if (isRecording) { voiceRecorder.stopRecording() if (!isRecordingCanceled) { currentRecordingFile?.let { viewModel.sendVoiceMessage(it) } } else { currentRecordingFile?.delete() } isRecording = false recordingOffset = 0f } }, onDragCancel = { if (isRecording) { voiceRecorder.stopRecording() currentRecordingFile?.delete() isRecording = false recordingOffset = 0f } } ) } } ) { Box(contentAlignment = Alignment.Center) { if (isRecording) { // Overlay for recording state? // For now just use the color and icon } Icon( imageVector = if (showMic) (if (isRecording) Icons.Default.Mic else Icons.Default.MicNone) else Icons.Default.Send, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp) ) } } } // Overlay text for recording cancellation (absolute positioning might be better but let's see) if (isRecording) { Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { Text( text = if (isRecordingCanceled) "Отменено" else "← Смахните для отмены", color = if (isRecordingCanceled) Color.Red else Color.Gray, style = MaterialTheme.typography.bodySmall, modifier = Modifier.align(Alignment.Center) ) } } } if (isEmojiPickerVisible) { LaunchedEffect(Unit) { viewModel.loadTrendingGifs() } MediaPicker( trendingGifs = state.trendingGifs, searchedGifs = state.searchedGifs, recentGifs = state.recentGifs, gifCategories = state.gifCategories, isGifsLoading = state.isGifsLoading, error = state.error, onEmojiSelected = { viewModel.onInputTextChanged(state.inputText + it) }, onGifSelected = { url -> viewModel.sendGif(url) isEmojiPickerVisible = false }, onGifSearch = { query -> viewModel.searchGifs(query) } ) LaunchedEffect(Unit) { viewModel.loadTrendingGifs() viewModel.loadGifCategories() } } } } } // Просмотрщик медиа selectedMediaList?.let { list -> AppMediaLightbox( mediaList = list, initialIndex = initialMediaIndex, onClose = { selectedMediaList = null } ) } if (state.forwardingMessages.isNotEmpty()) { ForwardChatSelectionDialog( chats = state.availableChatsToForward, onChatSelected = { targetChatId -> viewModel.forwardMessages(targetChatId, state.forwardingMessages) viewModel.cancelForwarding() viewModel.clearSelection() }, onDismiss = { viewModel.cancelForwarding() } ) } } @Composable fun ForwardChatSelectionDialog( chats: List, onChatSelected: (String) -> Unit, onDismiss: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, title = { Text("Выберите чат для пересылки") }, text = { LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp)) { items(chats) { chat -> Row( modifier = Modifier .fillMaxWidth() .clickable { onChatSelected(chat.id) } .padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically ) { AppAvatar(url = chat.avatar, name = chat.name, size = 40.dp) Spacer(Modifier.width(12.dp)) Text(chat.name, style = MaterialTheme.typography.bodyLarge) } Divider(color = Color.Gray.copy(alpha = 0.2f)) } } }, confirmButton = {}, dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } } ) } @Immutable sealed class MessageListItem { data class MessageItem(val message: chats.domain.model.Message) : MessageListItem() data class DateHeader(val date: String) : MessageListItem() object UnreadSeparator : MessageListItem() }