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 androidx.compose.animation.core.* import androidx.compose.animation.animateContentSize import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale import chats.presentation.components.SwipeableMessageItem import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ChatDetailScreen( chatId: String, chatName: String, viewModel: ChatDetailViewModel, onBack: () -> Unit ) { val state by viewModel.state.collectAsState() val context = LocalContext.current val listState = rememberLazyListState() val scope = rememberCoroutineScope() val voiceRecorder = remember { VoiceRecorder(context) } var isRecording by remember { mutableStateOf(false) } DisposableEffect(Unit) { onDispose { viewModel.clearChatId() if (isRecording) { voiceRecorder.stopRecording() } } } var isEmojiPickerVisible by remember { mutableStateOf(false) } var recordingOffset by remember { mutableFloatStateOf(0f) } var isRecordingCanceled by remember { mutableStateOf(false) } var currentRecordingFile by remember { mutableStateOf(null) } var recordingStartTime by remember { mutableLongStateOf(0L) } var recordingElapsedSeconds by remember { mutableIntStateOf(0) } val recordingScope = rememberCoroutineScope() val micPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted -> if (isGranted) { val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3") currentRecordingFile = file voiceRecorder.startRecording(file) isRecording = true isRecordingCanceled = false recordingOffset = 0f recordingStartTime = System.currentTimeMillis() recordingElapsedSeconds = 0 // Запускаем таймер recordingScope.launch { while (isRecording && !isRecordingCanceled) { delay(1000) recordingElapsedSeconds++ } } } } 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 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 try { // Удаляем дубликаты по ID сообщения val uniqueMessages = state.messages.distinctBy { it.id } if (uniqueMessages.isEmpty()) return@remember items // Группируем от старых к новым (для normal layout) val reversedMessages = uniqueMessages.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++ } // Добавляем заголовок даты val safeDateHeader = try { viewModel.formatDateHeader(isoDate) } catch (e: Exception) { isoDate.take(10) } items.add(MessageListItem.DateHeader(safeDateHeader)) // Добавляем сообщения dayMessages.forEach { msg -> items.add(MessageListItem.MessageItem(msg)) } } } catch (e: Exception) { android.util.Log.e("ChatDetailScreen", "Error building listItems", e) } items } // Выносим allChatMedia наружу - до LazyColumn val allChatMedia = remember(state.messages) { state.messages.flatMap { msg -> msg.media.map { it to msg.id } } } // Функция воспроизведения следующего голосового сообщения val playNextVoiceMessage = { currentId: String, speed: Float -> // Находим текущее сообщение в listItems (который от старых к новым) val currentIndex = listItems.indexOfFirst { it is MessageListItem.MessageItem && it.message.id == currentId } if (currentIndex != -1 && currentIndex < listItems.size - 1) { // Ищем следующее голосовое сообщение вперёд по списку (более новое) val nextVoiceIndexInSublist = listItems.subList(currentIndex + 1, listItems.size) .indexOfFirst { it is MessageListItem.MessageItem && it.message.mediaType == chats.domain.model.MediaType.AUDIO && it.message.content.isNullOrEmpty() } if (nextVoiceIndexInSublist != -1) { val actualNextIndex = currentIndex + 1 + nextVoiceIndexInSublist val nextItem = listItems[actualNextIndex] as MessageListItem.MessageItem autoPlayingMessageId = nextItem.message.id currentPlaybackSpeed = speed // Прокручиваем к следующему сообщению, если оно не видно scope.launch { val visibleIndexes = listState.layoutInfo.visibleItemsInfo.map { it.index } if (actualNextIndex !in visibleIndexes) { listState.animateScrollToItem(actualNextIndex) } } } else { autoPlayingMessageId = null } } else { autoPlayingMessageId = null } } // Отслеживаем текущую дату для плавающего заголовка 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 } } // Авто-скролл к новым сообщениям - только если мы уже внизу // Берём firstOrNull, т.к. messages отсортированы по sequenceId убыванию (новые в начале) val lastMessageId = state.messages.firstOrNull()?.id LaunchedEffect(lastMessageId) { if (lastMessageId != null && listItems.isNotEmpty()) { // Даём время LazyColumn отрисовать новое сообщение и обновить layout delay(200) val layoutInfo = listState.layoutInfo val visibleItems = layoutInfo.visibleItemsInfo val totalCount = layoutInfo.totalItemsCount if (totalCount > 0 && visibleItems.isNotEmpty()) { val lastVisibleIndex = visibleItems.last().index val atBottom = lastVisibleIndex >= totalCount - 2 android.util.Log.d("ChatDetailScreen", "Auto-scroll check: lastVisible=$lastVisibleIndex, total=$totalCount, atBottom=$atBottom, msgId=$lastMessageId") if (atBottom) { listState.animateScrollToItem(listItems.size - 1) } } } } // Автоматическая прокрутка при инициализации - только один раз var initialScrollDone by remember { mutableStateOf(false) } LaunchedEffect(listItems.size) { if (!initialScrollDone && listItems.isNotEmpty() && listItems.size > 1) { try { listState.scrollToItem(listItems.size - 1) initialScrollDone = true viewModel.onInitialScrollDone() viewModel.markAsRead() } catch (e: Exception) { android.util.Log.e("ChatDetailScreen", "Error in auto-scroll", e) } } } // Подгрузка истории при прокрутке вверх 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(Unit) { delay(500) 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() } } } // Авто-прочитка при нахождении внизу списка 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) ) } } } } } // Панель ввода + оверлей записи Box(modifier = Modifier.fillMaxWidth()) { // Основная панель 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() // Анимация пульсации для кнопки записи val pulseProgress = remember { Animatable(0f) } LaunchedEffect(isRecording) { if (isRecording) { pulseProgress.animateTo( targetValue = 1f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1000), repeatMode = RepeatMode.Reverse ) ) } else { pulseProgress.snapTo(0f) } } val micScale by animateFloatAsState( targetValue = if (isRecording) 1f + (pulseProgress.value * 0.15f) else 1f, label = "micScale" ) Box( contentAlignment = Alignment.Center, modifier = Modifier .size(48.dp) .scale(if (showMic) micScale else 1f) .clip(RoundedCornerShape(16.dp)) .background(if (showMic) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) else MaterialTheme.colorScheme.primary) .clickable(enabled = !showMic) { viewModel.sendMessage() isEmojiPickerVisible = false } .pointerInput(showMic) { if (showMic) { detectDragGesturesAfterLongPress( onDragStart = { android.util.Log.d("ChatDetailScreen", "onDragStart triggered") val hasMic = ContextCompat.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED android.util.Log.d("ChatDetailScreen", "Permission RECORD_AUDIO: $hasMic") if (hasMic) { try { val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3") currentRecordingFile = file android.util.Log.d("ChatDetailScreen", "Starting recording to: ${file.absolutePath}") voiceRecorder.startRecording(file) isRecording = true isRecordingCanceled = false recordingOffset = 0f recordingStartTime = System.currentTimeMillis() recordingElapsedSeconds = 0 // Запускаем таймер recordingScope.launch { while (isRecording && !isRecordingCanceled) { delay(1000) recordingElapsedSeconds++ } } } catch (e: Exception) { android.util.Log.e("ChatDetailScreen", "Error starting recording", e) } } else { android.util.Log.d("ChatDetailScreen", "Requesting mic permission") micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } }, onDrag = { change, dragAmount -> change.consume() recordingOffset += dragAmount.x if (recordingOffset < -100f) { isRecordingCanceled = true } }, onDragEnd = { android.util.Log.d("ChatDetailScreen", "onDragEnd: isRecording=$isRecording, isRecordingCanceled=$isRecordingCanceled") if (isRecording) { voiceRecorder.stopRecording() if (!isRecordingCanceled) { currentRecordingFile?.let { android.util.Log.d("ChatDetailScreen", "Sending voice message file: ${it.absolutePath}") viewModel.sendVoiceMessage(it) } ?: android.util.Log.e("ChatDetailScreen", "currentRecordingFile is null") } else { android.util.Log.d("ChatDetailScreen", "Recording canceled") currentRecordingFile?.delete() } isRecording = false isRecordingCanceled = false recordingOffset = 0f recordingElapsedSeconds = 0 } }, onDragCancel = { android.util.Log.d("ChatDetailScreen", "onDragCancel: isRecording=$isRecording") if (isRecording) { voiceRecorder.stopRecording() currentRecordingFile?.delete() isRecording = false isRecordingCanceled = false recordingOffset = 0f recordingElapsedSeconds = 0 } } ) } } ) { 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) ) } } 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() } } } // Оверлей записи - ПОВЕРХ всего (внутри BoxScope) if (isRecording) { val cancelIconScale by animateFloatAsState( targetValue = if (recordingOffset < -50f) { 1f + ((-recordingOffset - 50f) / 150f).coerceIn(0f, 1f) } else 0.8f, label = "cancelScale" ) val cancelIconAlpha by animateFloatAsState( targetValue = if (recordingOffset < -50f) 1f else 0.5f, label = "cancelAlpha" ) val formattedTime = String.format("%02d:%02d", recordingElapsedSeconds / 60, recordingElapsedSeconds % 60) Box( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) .background(Color.Black) .padding(horizontal = 16.dp, vertical = 12.dp) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .size(48.dp) .scale(cancelIconScale) .alpha(cancelIconAlpha), contentAlignment = Alignment.Center ) { Box( modifier = Modifier .fillMaxSize() .background( color = Color.Red.copy(alpha = 0.3f + (cancelIconAlpha * 0.4f)), shape = CircleShape ) ) Icon( Icons.Default.Close, contentDescription = "Cancel recording", tint = Color.Red, modifier = Modifier.size(32.dp) ) } Box( modifier = Modifier .background( color = Color.Red.copy(alpha = 0.3f), shape = RoundedCornerShape(16.dp) ) .padding(horizontal = 20.dp, vertical = 10.dp), contentAlignment = Alignment.Center ) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { PulseDot() Spacer(modifier = Modifier.width(10.dp)) Text( text = formattedTime, style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.SemiBold ) } } Spacer(modifier = Modifier.size(48.dp)) } } } } } } // Просмотрщик медиа 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() } @Composable fun PulseDot() { val infiniteTransition = rememberInfiniteTransition(label = "pulse") val scale by infiniteTransition.animateFloat( initialValue = 1f, targetValue = 1.5f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1000), repeatMode = RepeatMode.Reverse ), label = "scale" ) val alpha by infiniteTransition.animateFloat( initialValue = 1f, targetValue = 0.3f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1000), repeatMode = RepeatMode.Reverse ), label = "alpha" ) Box( modifier = Modifier.size(12.dp), contentAlignment = Alignment.Center ) { Box( modifier = Modifier .size(12.dp) .background(color = Color.Red, shape = CircleShape) .alpha(alpha) ) Box( modifier = Modifier .size(8.dp) .background(color = Color.Red, shape = CircleShape) ) } }