1291 lines
64 KiB
Kotlin
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)
|
|
)
|
|
}
|
|
}
|