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.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 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 ) { val state by viewModel.state.collectAsState() val context = LocalContext.current val listState = rememberLazyListState() val scope = rememberCoroutineScope() val voiceRecorder = remember { VoiceRecorder(context) } var textInput by remember { mutableStateOf("") } 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) } // Автопрокрутка к первому непрочитанному или к самому низу LaunchedEffect(state.initialScrollIndex) { state.initialScrollIndex?.let { index -> if (index < state.messages.size) { listState.scrollToItem(index) } } } // Автопрокрутка к новому сообщению, если мы внизу LaunchedEffect(state.messages.size) { if (state.messages.isNotEmpty()) { val isAtBottom = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.messages.size - 2 if (isAtBottom || state.initialScrollIndex == null) { listState.animateScrollToItem(state.messages.size - 1) } // Если пришли новые сообщения и мы их видим — помечаем как прочитанные if (isAtBottom) { 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(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { val allChatMedia = state.messages.flatMap { msg -> msg.media.map { it to msg.id } }.reversed() items(state.messages, key = { it.id }) { message -> MessageBubble( message = message, isCurrentUser = message.senderId == viewModel.getCurrentUserId(), autoPlay = message.id == autoPlayingMessageId, initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f, onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) }, onReactionClick = { emoji -> viewModel.addReaction(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) { // Ошибка (нет софта) } } } ) } } if (state.isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } // Панель ввода 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 = textInput, onValueChange = { textInput = it }, modifier = Modifier .weight(1f) .onFocusChanged { if (it.isFocused) { isEmojiPickerVisible = false } }, textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), decorationBox = { innerTextField -> if (textInput.isEmpty()) { Text( stringResource(R.string.message_placeholder), style = MaterialTheme.typography.bodyLarge, color = Color.Gray ) } innerTextField() } ) } } Spacer(modifier = Modifier.width(8.dp)) // 3. Кнопка действия (в синем квадрате) val showMic = textInput.isEmpty() && state.pendingAttachments.isEmpty() Surface( onClick = { if (!showMic) { viewModel.sendMessage(textInput) textInput = "" } }, 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 = { textInput += 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 } ) } }