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

569 lines
29 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.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import chats.presentation.components.MediaPicker
import chats.presentation.components.MessageBubble
import core.presentation.components.AppAvatar
import core.presentation.components.AppMediaLightbox
import core.utils.VoiceRecorder
import core.utils.copyUriToFile
import java.io.File
import ru.knot.messager.R
import kotlinx.coroutines.launch
import android.Manifest
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun ChatDetailScreen(
chatId: String,
chatName: String,
viewModel: ChatDetailViewModel,
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val voiceRecorder = remember { VoiceRecorder(context) }
var textInput by remember { mutableStateOf("") }
var isEmojiPickerVisible by remember { mutableStateOf(false) }
var isRecording by remember { mutableStateOf(false) }
var recordingOffset by remember { mutableFloatStateOf(0f) }
var isRecordingCanceled by remember { mutableStateOf(false) }
var currentRecordingFile by remember { mutableStateOf<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 playNextVoiceMessage = { currentId: String, speed: Float ->
val currentIndex = state.messages.indexOfFirst { it.id == currentId }
if (currentIndex != -1 && currentIndex < state.messages.size - 1) {
val nextVoiceIndexInSublist = state.messages.subList(currentIndex + 1, state.messages.size)
.indexOfFirst { it.mediaType == chats.domain.model.MediaType.AUDIO && it.content.isNullOrEmpty() }
if (nextVoiceIndexInSublist != -1) {
val actualNextIndex = currentIndex + 1 + nextVoiceIndexInSublist
autoPlayingMessageId = state.messages[actualNextIndex].id
currentPlaybackSpeed = speed
// Прокручиваем к следующему сообщению, иначе оно не распарсится LazyColumn
scope.launch {
listState.animateScrollToItem(actualNextIndex)
}
} else {
autoPlayingMessageId = null
}
} else {
autoPlayingMessageId = null
}
}
// Пикер галереи (Мультивыбор)
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia()
) { uris ->
uris.forEach { viewModel.addPendingAttachment(it, context) }
}
// Загрузка данных чата при входе
LaunchedEffect(chatId) {
viewModel.setChatId(chatId)
}
// Автопрокрутка к первому непрочитанному или к самому низу
LaunchedEffect(state.initialScrollIndex) {
state.initialScrollIndex?.let { index ->
if (index < state.messages.size) {
listState.scrollToItem(index)
}
}
}
// Автопрокрутка к новому сообщению, если мы внизу
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) {
val isAtBottom = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.messages.size - 2
if (isAtBottom || state.initialScrollIndex == null) {
listState.animateScrollToItem(state.messages.size - 1)
}
// Если пришли новые сообщения и мы их видим — помечаем как прочитанные
if (isAtBottom) {
viewModel.markAsRead()
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
AppAvatar(
url = state.chatAvatar,
name = state.chatName ?: chatName,
size = 36.dp,
modifier = Modifier.padding(end = 8.dp)
)
Column {
Text(state.chatName ?: chatName, style = MaterialTheme.typography.titleMedium)
if (state.isTyping) {
Text(
stringResource(R.string.typing),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
if (state.canCall) {
IconButton(onClick = { /* Вызов (WebRTC) */ }) {
Icon(Icons.Default.Call, contentDescription = stringResource(R.string.call))
}
IconButton(onClick = { /* Видеозвонок (WebRTC) */ }) {
Icon(Icons.Default.VideoCall, contentDescription = stringResource(R.string.video_call))
}
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.imePadding()
) {
// Список сообщений
Box(
modifier = Modifier
.weight(1f)
.background(MaterialTheme.colorScheme.background)
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val allChatMedia = state.messages.flatMap { msg ->
msg.media.map { it to msg.id }
}.reversed()
items(state.messages, key = { it.id }) { message ->
MessageBubble(
message = message,
isCurrentUser = message.senderId == viewModel.getCurrentUserId(),
autoPlay = message.id == autoPlayingMessageId,
initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) },
onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) },
onMediaClick = { clickedMedia ->
val isMedia = clickedMedia.type.startsWith("image") ||
clickedMedia.type.startsWith("video") ||
clickedMedia.type.contains("gif")
if (isMedia) {
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url }
selectedMediaList = allChatMedia.map { it.first }
initialMediaIndex = if (initialIndex != -1) initialIndex else 0
} else {
// Прямое открытие документов во внешних приложениях
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url))
context.startActivity(intent)
} catch (e: Exception) {
// Ошибка (нет софта)
}
}
}
)
}
}
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
// Панель ввода
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
// Pending Attachments Bar
if (state.pendingAttachments.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Вложения (${state.pendingAttachments.size})",
style = MaterialTheme.typography.labelMedium,
color = Color.Gray,
modifier = Modifier.weight(1f)
)
// HQ Toggle moved here
if (state.pendingAttachments.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp") }) {
TextButton(
onClick = { viewModel.toggleCompression() },
modifier = Modifier.height(30.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
) {
Icon(
imageVector = if (!state.isCompressionEnabled) Icons.Default.HighQuality else Icons.Default.Hd,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = if (!state.isCompressionEnabled) MaterialTheme.colorScheme.primary else Color.Gray
)
Spacer(Modifier.width(4.dp))
Text(
text = if (!state.isCompressionEnabled) "Без сжатия" else "Сжать (HD)",
fontSize = 11.sp,
color = if (!state.isCompressionEnabled) MaterialTheme.colorScheme.primary else Color.Gray
)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
androidx.compose.foundation.lazy.LazyRow(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.pendingAttachments) { file ->
Box(
modifier = Modifier
.size(80.dp)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
val isImage = file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif")
val isVideo = file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm")
if (isImage || isVideo) {
androidx.compose.foundation.Image(
painter = rememberAsyncImagePainter(file),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
if (isVideo) {
Icon(
Icons.Default.PlayCircle,
contentDescription = null,
modifier = Modifier.align(Alignment.Center).size(32.dp),
tint = Color.White.copy(alpha = 0.8f)
)
}
} else {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.InsertDriveFile,
contentDescription = null,
modifier = Modifier.size(32.dp)
)
Text(
text = file.extension.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Кнопка закрытия (Centered properly)
Surface(
onClick = { viewModel.removePendingAttachment(file) },
color = Color.Black.copy(alpha = 0.5f),
shape = CircleShape,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(20.dp)
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
tint = Color.White,
modifier = Modifier.padding(4.dp)
)
}
}
}
}
if (state.isUploading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp).padding(4.dp))
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Скрепка (слева)
IconButton(onClick = {
galleryLauncher.launch(
androidx.activity.result.PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
)
}) {
Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach), tint = Color.Gray)
}
// 2. Поле ввода + Эмодзи (в одном баббле)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f))
.padding(horizontal = 4.dp, vertical = 4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = {
isEmojiPickerVisible = !isEmojiPickerVisible
if (isEmojiPickerVisible) {
keyboardController?.hide()
focusManager.clearFocus()
}
}
) {
Icon(
Icons.Default.EmojiEmotions,
contentDescription = stringResource(R.string.emoji),
tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray,
modifier = Modifier.size(24.dp)
)
}
BasicTextField(
value = textInput,
onValueChange = { textInput = it },
modifier = Modifier
.weight(1f)
.onFocusChanged {
if (it.isFocused) {
isEmojiPickerVisible = false
}
},
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
decorationBox = { innerTextField ->
if (textInput.isEmpty()) {
Text(
stringResource(R.string.message_placeholder),
style = MaterialTheme.typography.bodyLarge,
color = Color.Gray
)
}
innerTextField()
}
)
}
}
Spacer(modifier = Modifier.width(8.dp))
// 3. Кнопка действия (в синем квадрате)
val showMic = textInput.isEmpty() && state.pendingAttachments.isEmpty()
Surface(
onClick = {
if (!showMic) {
viewModel.sendMessage(textInput)
textInput = ""
}
},
shape = RoundedCornerShape(16.dp),
color = if (showMic) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) else MaterialTheme.colorScheme.primary,
modifier = Modifier
.size(48.dp)
.pointerInput(Unit) {
if (showMic) {
detectDragGesturesAfterLongPress(
onDragStart = {
val hasMic = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
if (hasMic) {
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
currentRecordingFile = file
voiceRecorder.startRecording(file)
isRecording = true
isRecordingCanceled = false
recordingOffset = 0f
} else {
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
onDrag = { change, dragAmount ->
change.consume()
recordingOffset += dragAmount.x
if (recordingOffset < -200f) {
isRecordingCanceled = true
}
},
onDragEnd = {
if (isRecording) {
voiceRecorder.stopRecording()
if (!isRecordingCanceled) {
currentRecordingFile?.let { viewModel.sendVoiceMessage(it) }
} else {
currentRecordingFile?.delete()
}
isRecording = false
recordingOffset = 0f
}
},
onDragCancel = {
if (isRecording) {
voiceRecorder.stopRecording()
currentRecordingFile?.delete()
isRecording = false
recordingOffset = 0f
}
}
)
}
}
) {
Box(contentAlignment = Alignment.Center) {
if (isRecording) {
// Overlay for recording state?
// For now just use the color and icon
}
Icon(
imageVector = if (showMic) (if (isRecording) Icons.Default.Mic else Icons.Default.MicNone) else Icons.Default.Send,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
}
// Overlay text for recording cancellation (absolute positioning might be better but let's see)
if (isRecording) {
Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Text(
text = if (isRecordingCanceled) "Отменено" else "← Смахните для отмены",
color = if (isRecordingCanceled) Color.Red else Color.Gray,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
if (isEmojiPickerVisible) {
LaunchedEffect(Unit) {
viewModel.loadTrendingGifs()
}
MediaPicker(
trendingGifs = state.trendingGifs,
searchedGifs = state.searchedGifs,
recentGifs = state.recentGifs,
gifCategories = state.gifCategories,
isGifsLoading = state.isGifsLoading,
error = state.error,
onEmojiSelected = { textInput += it },
onGifSelected = { url ->
viewModel.sendGif(url)
isEmojiPickerVisible = false
},
onGifSearch = { query ->
viewModel.searchGifs(query)
}
)
LaunchedEffect(Unit) {
viewModel.loadTrendingGifs()
viewModel.loadGifCategories()
}
}
}
}
// Просмотрщик медиа
selectedMediaList?.let { list ->
AppMediaLightbox(
mediaList = list,
initialIndex = initialMediaIndex,
onClose = { selectedMediaList = null }
)
}
}