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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.ui.unit.sp import coil.compose.rememberAsyncImagePainter @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 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 // Группируем от новых к старым (Index 0 = bottom) var i = 0 while (i < state.messages.size) { val isoDate = state.messages[i].createdAt // Получаем дату в локальном часовом поясе (например "2024-04-15") val localDate = viewModel.getLocalDateString(isoDate) val dayMessages = mutableListOf() // Собираем все сообщения за этот локальный день while (i < state.messages.size && viewModel.getLocalDateString(state.messages[i].createdAt) == localDate) { dayMessages.add(state.messages[i]) i++ } // Добавляем сообщения в инвертированном порядке dayMessages.forEach { msg -> items.add(MessageListItem.MessageItem(msg)) } // Добавляем заголовок (над группой сообщений) items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate))) } items } // Отслеживаем текущую дату для плавающего заголовка val floatingDate by remember { derivedStateOf { val firstIndex = listState.firstVisibleItemIndex // Не показываем плашку на самом первом элементе (там и так дата) // И показываем только если мы прокрутили хотя бы немного if (firstIndex > 0 && firstIndex < listItems.size) { val item = listItems[firstIndex] 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 if (layoutInfo.totalItemsCount == 0) return@derivedStateOf false val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 // Показываем, если не в самом низу (с запасом 1-2 элемента) lastVisibleIndex < listItems.size - 2 } } // 4. Авто-скролл к новым сообщениям val firstMessageId = state.messages.firstOrNull()?.id LaunchedEffect(firstMessageId) { if (firstMessageId != null) { // Если мы уже внизу (индекс 0) или это наше сообщение - скроллим вниз val isAtBottom = listState.firstVisibleItemIndex <= 1 val isMyMessage = state.messages.firstOrNull()?.senderId == viewModel.getCurrentUserId() if (isAtBottom || isMyMessage) { listState.animateScrollToItem(0) // Если мы внизу - помечаем сразу как прочитанное if (isAtBottom && !isMyMessage) { viewModel.markAsRead() } } } } // Автоматическая прокрутка и прочтение при инициализации и новых сообщениях LaunchedEffect(listItems.size) { if (listItems.isNotEmpty()) { if (state.initialScrollIndex != null && state.initialScrollIndex != -1) { // Первый вход в чат - мы и так внизу из-за reverseLayout=true, но можем форсированно прокрутить к 0 listState.scrollToItem(0) viewModel.onInitialScrollDone() viewModel.markAsRead() } } } // Подгрузка истории при прокрутке вверх LaunchedEffect(listState) { snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } .collect { lastVisibleIndex -> if (lastVisibleIndex != null && lastVisibleIndex >= listItems.size - 10 && listItems.size >= 15) { android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, size ${listItems.size}") viewModel.loadMoreMessages() } } } val unreadCount = remember(state.messages) { state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() } } // Авто-прочитка при нахождении внизу списка (в reverseLayout это индекс 0) LaunchedEffect(listState.isScrollInProgress, listState.firstVisibleItemIndex) { if (!listState.isScrollInProgress && listState.firstVisibleItemIndex <= 1) { viewModel.markAsRead() } } Scaffold( topBar = { 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(), reverseLayout = true, contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { val allChatMedia = state.messages.flatMap { msg -> msg.media.map { it to msg.id } }.reversed() items(listItems, key = { when(it) { is MessageListItem.MessageItem -> it.message.id is MessageListItem.DateHeader -> "date_${it.date}" MessageListItem.UnreadSeparator -> "unread_separator" } }) { item -> when(item) { is MessageListItem.MessageItem -> { 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) }, 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) ) { FloatingActionButton( onClick = { scope.launch { listState.animateScrollToItem(listItems.size - 1) } }, containerColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.primary, shape = CircleShape, modifier = Modifier.size(44.dp), elevation = FloatingActionButtonDefaults.elevation(2.dp) ) { Icon(Icons.Default.KeyboardArrowDown, contentDescription = null) } 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) ) } } } } } // Панель ввода 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)) } } } 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) .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 } ) } } sealed class MessageListItem { data class MessageItem(val message: chats.domain.model.Message) : MessageListItem() data class DateHeader(val date: String) : MessageListItem() object UnreadSeparator : MessageListItem() }