Files
forkmessager/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt
T

1291 lines
64 KiB
Kotlin

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<File?>(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<String?>(null) }
var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) }
var selectedMediaList by remember { mutableStateOf<List<chats.domain.model.Media>?>(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<MessageListItem>()
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<chats.domain.model.Message>()
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<chats.domain.model.Chat>,
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)
)
}
}