325 lines
14 KiB
Kotlin
325 lines
14 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.runtime.*
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalFocusManager
|
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.focus.onFocusChanged
|
|
import androidx.compose.ui.unit.dp
|
|
import chats.presentation.components.MediaPicker
|
|
import chats.presentation.components.MessageBubble
|
|
import core.presentation.components.AppAvatar
|
|
import core.presentation.components.AppMediaLightbox
|
|
import core.utils.VoiceRecorder
|
|
import core.utils.copyUriToFile
|
|
import java.io.File
|
|
import ru.knot.messager.R
|
|
import kotlinx.coroutines.launch
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
|
@Composable
|
|
fun ChatDetailScreen(
|
|
chatId: String,
|
|
chatName: String,
|
|
viewModel: ChatDetailViewModel,
|
|
onBack: () -> Unit
|
|
) {
|
|
val state by viewModel.state.collectAsState()
|
|
val context = LocalContext.current
|
|
val listState = rememberLazyListState()
|
|
val scope = rememberCoroutineScope()
|
|
val voiceRecorder = remember { VoiceRecorder(context) }
|
|
var textInput by remember { mutableStateOf("") }
|
|
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
|
var isRecording by remember { mutableStateOf(false) }
|
|
|
|
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 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.GetContent()
|
|
) { uri: Uri? ->
|
|
uri?.let {
|
|
val file = copyUriToFile(context, it)
|
|
file?.let { viewModel.uploadMedia(it) }
|
|
}
|
|
}
|
|
|
|
// Загрузка данных чата при входе
|
|
LaunchedEffect(chatId) {
|
|
viewModel.setChatId(chatId)
|
|
}
|
|
|
|
// Автопрокрутка к первому непрочитанному или к самому низу
|
|
LaunchedEffect(state.initialScrollIndex) {
|
|
state.initialScrollIndex?.let { index ->
|
|
if (index < state.messages.size) {
|
|
listState.scrollToItem(index)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Автопрокрутка к новому сообщению, если мы внизу
|
|
LaunchedEffect(state.messages.size) {
|
|
if (state.messages.isNotEmpty()) {
|
|
val isAtBottom = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.messages.size - 2
|
|
if (isAtBottom || state.initialScrollIndex == null) {
|
|
listState.animateScrollToItem(state.messages.size - 1)
|
|
}
|
|
|
|
// Если пришли новые сообщения и мы их видим — помечаем как прочитанные
|
|
if (isAtBottom) {
|
|
viewModel.markAsRead()
|
|
}
|
|
}
|
|
}
|
|
|
|
Scaffold(
|
|
topBar = {
|
|
TopAppBar(
|
|
title = {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
AppAvatar(
|
|
url = state.chatAvatar,
|
|
name = state.chatName ?: chatName,
|
|
size = 36.dp,
|
|
modifier = Modifier.padding(end = 8.dp)
|
|
)
|
|
Column {
|
|
Text(state.chatName ?: chatName, style = MaterialTheme.typography.titleMedium)
|
|
if (state.isTyping) {
|
|
Text(
|
|
stringResource(R.string.typing),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.primary
|
|
)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
navigationIcon = {
|
|
IconButton(onClick = onBack) {
|
|
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
|
}
|
|
},
|
|
actions = {
|
|
if (state.canCall) {
|
|
IconButton(onClick = { /* Вызов (WebRTC) */ }) {
|
|
Icon(Icons.Default.Call, contentDescription = stringResource(R.string.call))
|
|
}
|
|
IconButton(onClick = { /* Видеозвонок (WebRTC) */ }) {
|
|
Icon(Icons.Default.VideoCall, contentDescription = stringResource(R.string.video_call))
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
) { paddingValues ->
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(paddingValues)
|
|
.imePadding()
|
|
) {
|
|
// Список сообщений
|
|
Box(
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.background(MaterialTheme.colorScheme.background)
|
|
) {
|
|
LazyColumn(
|
|
state = listState,
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentPadding = PaddingValues(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
) {
|
|
val allChatMedia = state.messages.flatMap { msg ->
|
|
msg.media.filter {
|
|
it.type.startsWith("image") ||
|
|
it.type.startsWith("video") ||
|
|
it.filename?.endsWith(".gif", true) == true
|
|
}.map { it to msg.id }
|
|
}.reversed()
|
|
|
|
items(state.messages, key = { it.id }) { message ->
|
|
MessageBubble(
|
|
message = message,
|
|
isCurrentUser = message.senderId == viewModel.getCurrentUserId(),
|
|
autoPlay = message.id == autoPlayingMessageId,
|
|
initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
|
|
onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) },
|
|
onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) },
|
|
onMediaClick = { clickedMedia ->
|
|
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url }
|
|
selectedMediaList = allChatMedia.map { it.first }
|
|
initialMediaIndex = if (initialIndex != -1) initialIndex else 0
|
|
}
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
if (state.isLoading) {
|
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
|
}
|
|
}
|
|
|
|
// Панель ввода
|
|
Column {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(8.dp),
|
|
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
|
|
)
|
|
}
|
|
IconButton(onClick = { galleryLauncher.launch("*/*") }) {
|
|
Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach))
|
|
}
|
|
|
|
TextField(
|
|
value = textInput,
|
|
onValueChange = { textInput = it },
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.onFocusChanged {
|
|
if (it.isFocused) {
|
|
isEmojiPickerVisible = false
|
|
}
|
|
},
|
|
placeholder = { Text(stringResource(R.string.message_placeholder)) },
|
|
maxLines = 4,
|
|
colors = TextFieldDefaults.colors(
|
|
focusedIndicatorColor = Color.Transparent,
|
|
unfocusedIndicatorColor = Color.Transparent
|
|
)
|
|
)
|
|
|
|
if (textInput.isBlank()) {
|
|
IconButton(
|
|
onClick = {
|
|
if (!isRecording) {
|
|
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
|
|
voiceRecorder.startRecording(file)
|
|
isRecording = true
|
|
} else {
|
|
voiceRecorder.stopRecording()
|
|
isRecording = false
|
|
}
|
|
}
|
|
) {
|
|
Icon(
|
|
if (isRecording) Icons.Default.Stop else Icons.Default.Mic,
|
|
contentDescription = stringResource(R.string.voice_message),
|
|
tint = if (isRecording) Color.Red else Color.Gray
|
|
)
|
|
}
|
|
} else {
|
|
IconButton(onClick = {
|
|
viewModel.sendMessage(textInput)
|
|
textInput = ""
|
|
}) {
|
|
Icon(Icons.Default.Send, contentDescription = stringResource(R.string.send))
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isEmojiPickerVisible) {
|
|
LaunchedEffect(Unit) {
|
|
viewModel.loadTrendingGifs()
|
|
}
|
|
MediaPicker(
|
|
trendingGifs = state.trendingGifs,
|
|
searchedGifs = state.searchedGifs,
|
|
recentGifs = state.recentGifs,
|
|
gifCategories = state.gifCategories,
|
|
isGifsLoading = state.isGifsLoading,
|
|
error = state.error,
|
|
onEmojiSelected = { textInput += it },
|
|
onGifSelected = { url ->
|
|
viewModel.sendGif(url)
|
|
isEmojiPickerVisible = false
|
|
},
|
|
onGifSearch = { query ->
|
|
viewModel.searchGifs(query)
|
|
}
|
|
)
|
|
|
|
LaunchedEffect(Unit) {
|
|
viewModel.loadTrendingGifs()
|
|
viewModel.loadGifCategories()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Просмотрщик медиа
|
|
selectedMediaList?.let { list ->
|
|
AppMediaLightbox(
|
|
mediaList = list,
|
|
initialIndex = initialMediaIndex,
|
|
onClose = { selectedMediaList = null }
|
|
)
|
|
}
|
|
}
|
|
}
|