873 lines
44 KiB
Kotlin
873 lines
44 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 chats.presentation.components.SwipeableMessageItem
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.focus.FocusRequester
|
|
import androidx.compose.ui.focus.focusRequester
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
|
@Composable
|
|
fun ChatDetailScreen(
|
|
chatId: String,
|
|
chatName: String,
|
|
viewModel: ChatDetailViewModel,
|
|
onBack: () -> Unit
|
|
) {
|
|
DisposableEffect(Unit) {
|
|
onDispose {
|
|
viewModel.clearChatId()
|
|
}
|
|
}
|
|
val state by viewModel.state.collectAsState()
|
|
val context = LocalContext.current
|
|
val listState = rememberLazyListState()
|
|
val scope = rememberCoroutineScope()
|
|
|
|
val voiceRecorder = remember { VoiceRecorder(context) }
|
|
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
|
var isRecording by remember { mutableStateOf(false) }
|
|
var recordingOffset by remember { mutableFloatStateOf(0f) }
|
|
var isRecordingCanceled by remember { mutableStateOf(false) }
|
|
var currentRecordingFile by remember { mutableStateOf<File?>(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<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 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<MessageListItem>()
|
|
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<chats.domain.model.Message>()
|
|
|
|
// Собираем все сообщения за этот локальный день
|
|
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 {
|
|
listState.firstVisibleItemIndex > 5
|
|
}
|
|
}
|
|
|
|
// 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, state.messages.size) {
|
|
snapshotFlow {
|
|
val lastIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
|
val totalCount = listState.layoutInfo.totalItemsCount
|
|
lastIndex to totalCount
|
|
}
|
|
.collect { (lastVisibleIndex, totalCount) ->
|
|
if (totalCount > 0 && lastVisibleIndex >= totalCount - 20 && !state.isLoadingMore) {
|
|
android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, total $totalCount")
|
|
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 -> {
|
|
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) },
|
|
onReplyClick = { reply ->
|
|
// TODO: Scroll to reply
|
|
val index = listItems.indexOfFirst {
|
|
it is MessageListItem.MessageItem && it.message.id == reply.id
|
|
}
|
|
if (index != -1) {
|
|
scope.launch { listState.animateScrollToItem(index) }
|
|
}
|
|
},
|
|
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(0)
|
|
}
|
|
},
|
|
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)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Панель ввода
|
|
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)
|
|
) {
|
|
Text(
|
|
text = "Ответ " + (if (replyMsg.senderId == viewModel.getCurrentUserId()) "самом себе" else replyMsg.senderName),
|
|
style = MaterialTheme.typography.labelMedium,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
Text(
|
|
text = replyMsg.content ?: if (replyMsg.media.isNotEmpty()) "Медиа" else "...",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
}
|
|
IconButton(onClick = { viewModel.cancelReply() }) {
|
|
Icon(Icons.Default.Close, contentDescription = "Cancel", modifier = Modifier.size(20.dp), tint = Color.Gray)
|
|
}
|
|
}
|
|
}
|
|
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
// 1. Скрепка (слева)
|
|
IconButton(onClick = {
|
|
galleryLauncher.launch(
|
|
androidx.activity.result.PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
|
|
)
|
|
}) {
|
|
Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach), tint = Color.Gray)
|
|
}
|
|
|
|
// 2. Поле ввода + Эмодзи (в одном баббле)
|
|
Box(
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.clip(RoundedCornerShape(24.dp))
|
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f))
|
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
|
) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
IconButton(
|
|
onClick = {
|
|
isEmojiPickerVisible = !isEmojiPickerVisible
|
|
if (isEmojiPickerVisible) {
|
|
keyboardController?.hide()
|
|
focusManager.clearFocus()
|
|
}
|
|
}
|
|
) {
|
|
Icon(
|
|
Icons.Default.EmojiEmotions,
|
|
contentDescription = stringResource(R.string.emoji),
|
|
tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray,
|
|
modifier = Modifier.size(24.dp)
|
|
)
|
|
}
|
|
|
|
BasicTextField(
|
|
value = state.inputText,
|
|
onValueChange = { viewModel.onInputTextChanged(it) },
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.focusRequester(focusRequester)
|
|
.onFocusChanged {
|
|
if (it.isFocused) {
|
|
isEmojiPickerVisible = false
|
|
}
|
|
},
|
|
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
|
cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary),
|
|
keyboardOptions = KeyboardOptions(
|
|
imeAction = ImeAction.Default,
|
|
capitalization = KeyboardCapitalization.Sentences
|
|
),
|
|
maxLines = 6,
|
|
decorationBox = { innerTextField ->
|
|
if (state.inputText.isEmpty()) {
|
|
Text(
|
|
stringResource(R.string.message_placeholder),
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = Color.Gray
|
|
)
|
|
}
|
|
innerTextField()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
|
|
// 3. Кнопка действия (в синем квадрате)
|
|
val showMic = state.inputText.isEmpty() && state.pendingAttachments.isEmpty()
|
|
|
|
Surface(
|
|
onClick = {
|
|
if (!showMic) {
|
|
viewModel.sendMessage()
|
|
isEmojiPickerVisible = false
|
|
}
|
|
},
|
|
shape = RoundedCornerShape(16.dp),
|
|
color = if (showMic) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) else MaterialTheme.colorScheme.primary,
|
|
modifier = Modifier
|
|
.size(48.dp)
|
|
.pointerInput(Unit) {
|
|
if (showMic) {
|
|
detectDragGesturesAfterLongPress(
|
|
onDragStart = {
|
|
val hasMic = ContextCompat.checkSelfPermission(
|
|
context, Manifest.permission.RECORD_AUDIO
|
|
) == PackageManager.PERMISSION_GRANTED
|
|
if (hasMic) {
|
|
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
|
|
currentRecordingFile = file
|
|
voiceRecorder.startRecording(file)
|
|
isRecording = true
|
|
isRecordingCanceled = false
|
|
recordingOffset = 0f
|
|
} else {
|
|
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
|
}
|
|
},
|
|
onDrag = { change, dragAmount ->
|
|
change.consume()
|
|
recordingOffset += dragAmount.x
|
|
if (recordingOffset < -200f) {
|
|
isRecordingCanceled = true
|
|
}
|
|
},
|
|
onDragEnd = {
|
|
if (isRecording) {
|
|
voiceRecorder.stopRecording()
|
|
if (!isRecordingCanceled) {
|
|
currentRecordingFile?.let { viewModel.sendVoiceMessage(it) }
|
|
} else {
|
|
currentRecordingFile?.delete()
|
|
}
|
|
isRecording = false
|
|
recordingOffset = 0f
|
|
}
|
|
},
|
|
onDragCancel = {
|
|
if (isRecording) {
|
|
voiceRecorder.stopRecording()
|
|
currentRecordingFile?.delete()
|
|
isRecording = false
|
|
recordingOffset = 0f
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
) {
|
|
Box(contentAlignment = Alignment.Center) {
|
|
if (isRecording) {
|
|
// Overlay for recording state?
|
|
// For now just use the color and icon
|
|
}
|
|
Icon(
|
|
imageVector = if (showMic) (if (isRecording) Icons.Default.Mic else Icons.Default.MicNone) else Icons.Default.Send,
|
|
contentDescription = null,
|
|
tint = Color.White,
|
|
modifier = Modifier.size(24.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Overlay text for recording cancellation (absolute positioning might be better but let's see)
|
|
if (isRecording) {
|
|
Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
|
Text(
|
|
text = if (isRecordingCanceled) "Отменено" else "← Смахните для отмены",
|
|
color = if (isRecordingCanceled) Color.Red else Color.Gray,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
modifier = Modifier.align(Alignment.Center)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isEmojiPickerVisible) {
|
|
LaunchedEffect(Unit) {
|
|
viewModel.loadTrendingGifs()
|
|
}
|
|
MediaPicker(
|
|
trendingGifs = state.trendingGifs,
|
|
searchedGifs = state.searchedGifs,
|
|
recentGifs = state.recentGifs,
|
|
gifCategories = state.gifCategories,
|
|
isGifsLoading = state.isGifsLoading,
|
|
error = state.error,
|
|
onEmojiSelected = { viewModel.onInputTextChanged(state.inputText + it) },
|
|
onGifSelected = { url ->
|
|
viewModel.sendGif(url)
|
|
isEmojiPickerVisible = false
|
|
},
|
|
onGifSearch = { query ->
|
|
viewModel.searchGifs(query)
|
|
}
|
|
)
|
|
|
|
LaunchedEffect(Unit) {
|
|
viewModel.loadTrendingGifs()
|
|
viewModel.loadGifCategories()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Просмотрщик медиа
|
|
selectedMediaList?.let { list ->
|
|
AppMediaLightbox(
|
|
mediaList = list,
|
|
initialIndex = initialMediaIndex,
|
|
onClose = { selectedMediaList = null }
|
|
)
|
|
}
|
|
}
|
|
|
|
sealed class MessageListItem {
|
|
data class MessageItem(val message: chats.domain.model.Message) : MessageListItem()
|
|
data class DateHeader(val date: String) : MessageListItem()
|
|
object UnreadSeparator : MessageListItem()
|
|
}
|