Чат, вложения

This commit is contained in:
Халимов Рустам
2026-04-14 21:53:44 +03:00
parent 118f8b8971
commit 58fdf1aca1
22 changed files with 799 additions and 125 deletions
@@ -11,7 +11,14 @@ 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
@@ -30,6 +37,15 @@ 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
@@ -47,6 +63,18 @@ fun ChatDetailScreen(
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
@@ -80,14 +108,11 @@ fun ChatDetailScreen(
}
}
// Пикер галереи
// Пикер галереи (Мультивыбор)
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
val file = copyUriToFile(context, it)
file?.let { viewModel.uploadMedia(it) }
}
contract = ActivityResultContracts.PickMultipleVisualMedia()
) { uris ->
uris.forEach { viewModel.addPendingAttachment(it, context) }
}
// Загрузка данных чата при входе
@@ -179,11 +204,7 @@ fun ChatDetailScreen(
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 }
msg.media.map { it to msg.id }
}.reversed()
items(state.messages, key = { it.id }) { message ->
@@ -195,9 +216,23 @@ fun ChatDetailScreen(
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
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) {
// Ошибка (нет софта)
}
}
}
)
}
@@ -210,79 +245,289 @@ fun ChatDetailScreen(
}
// Панель ввода
Column {
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(8.dp),
.padding(horizontal = 8.dp, vertical = 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
// 1. Скрепка (слева)
IconButton(onClick = {
galleryLauncher.launch(
androidx.activity.result.PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
)
}
IconButton(onClick = { galleryLauncher.launch("*/*") }) {
Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach))
}) {
Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach), tint = Color.Gray)
}
TextField(
value = textInput,
onValueChange = { textInput = it },
// 2. Поле ввода + Эмодзи (в одном баббле)
Box(
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
.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)
)
}
) {
Icon(
if (isRecording) Icons.Default.Stop else Icons.Default.Mic,
contentDescription = stringResource(R.string.voice_message),
tint = if (isRecording) Color.Red else Color.Gray
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()
}
)
}
} else {
IconButton(onClick = {
viewModel.sendMessage(textInput)
textInput = ""
}) {
Icon(Icons.Default.Send, contentDescription = stringResource(R.string.send))
}
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()
@@ -309,16 +554,15 @@ fun ChatDetailScreen(
viewModel.loadGifCategories()
}
}
}
}
// Просмотрщик медиа
selectedMediaList?.let { list ->
AppMediaLightbox(
mediaList = list,
initialIndex = initialMediaIndex,
onClose = { selectedMediaList = null }
)
}
}
// Просмотрщик медиа
selectedMediaList?.let { list ->
AppMediaLightbox(
mediaList = list,
initialIndex = initialMediaIndex,
onClose = { selectedMediaList = null }
)
}
}
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import core.utils.copyUriToFile
import core.utils.ImageUtils
import chats.data.remote.api.KlipyGifDto
@@ -34,7 +36,10 @@ data class ChatDetailState(
val recentGifs: List<KlipyGifDto> = emptyList(),
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
val isGifsLoading: Boolean = false,
val initialScrollIndex: Int? = null
val initialScrollIndex: Int? = null,
val pendingAttachments: List<File> = emptyList(),
val isUploading: Boolean = false,
val isCompressionEnabled: Boolean = true
)
@HiltViewModel
@@ -42,7 +47,8 @@ class ChatDetailViewModel @Inject constructor(
private val repository: ChatRepository,
private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager
private val tokenManager: TokenManager,
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
) : ViewModel() {
private val _state = MutableStateFlow(ChatDetailState())
@@ -52,14 +58,26 @@ class ChatDetailViewModel @Inject constructor(
private var typingTimerJob: Job? = null
private var lastTypingSentTime: Long = 0
private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE)
init {
val config = serverConfig.getServerConfig()
val savedCompression = prefs.getBoolean("compression_enabled", true)
_state.update { it.copy(
canCall = config.features.calls,
maxFileSize = config.limits.maxFileSize
maxFileSize = config.limits.maxFileSize,
isCompressionEnabled = savedCompression
) }
}
fun toggleCompression() {
_state.update { currentState ->
val newValue = !currentState.isCompressionEnabled
prefs.edit().putBoolean("compression_enabled", newValue).apply()
currentState.copy(isCompressionEnabled = newValue)
}
}
fun getCurrentUserId(): String {
return tokenManager.getUserId() ?: ""
}
@@ -181,18 +199,27 @@ class ChatDetailViewModel @Inject constructor(
}
fun sendMessage(text: String) {
val chatId = currentChatId ?: return
if (text.isBlank()) return
val pending = _state.value.pendingAttachments
if (text.isBlank() && pending.isEmpty()) return
val tempId = "temp_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
// Determine mediaType based on attachments
val mediaType = when {
pending.isEmpty() -> chats.domain.model.MediaType.TEXT
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> chats.domain.model.MediaType.IMAGE
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> chats.domain.model.MediaType.VIDEO
else -> chats.domain.model.MediaType.TEXT
}
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = text,
content = if (text.isBlank()) null else text,
createdAt = java.util.Date().toString(),
mediaType = chats.domain.model.MediaType.TEXT,
mediaType = mediaType,
media = emptyList(),
senderName = "Вы",
senderAvatar = null,
@@ -207,12 +234,40 @@ class ChatDetailViewModel @Inject constructor(
viewModelScope.launch {
try {
val sentMessage = repository.sendMessage(chatId, text)
// Upload attachments if any
val attachmentRequests = if (_state.value.pendingAttachments.isNotEmpty()) {
_state.update { it.copy(isUploading = true) }
val requests = _state.value.pendingAttachments.map { file ->
val url = repository.uploadMedia(file)
chats.data.remote.api.AttachmentRequest(
type = when {
file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") -> "image"
file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") -> "video"
file.extension.lowercase() in listOf("mp3", "m4a", "wav", "aac", "ogg") -> "audio"
else -> "file"
},
url = url,
fileName = file.name,
fileSize = file.length()
)
}
_state.update { it.copy(isUploading = false, pendingAttachments = emptyList()) }
requests
} else {
null
}
val sentMessage = repository.sendMessage(
chatId = chatId,
content = text,
type = if (attachmentRequests != null) "media" else "text",
attachments = attachmentRequests
)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage) }
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
}
}
}
@@ -227,6 +282,77 @@ class ChatDetailViewModel @Inject constructor(
}
}
fun sendVoiceMessage(file: File) {
val chatId = currentChatId ?: return
val tempId = "temp_voice_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = null,
createdAt = java.util.Date().toString(),
mediaType = chats.domain.model.MediaType.AUDIO,
media = emptyList(),
senderName = "Вы",
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
}
viewModelScope.launch {
try {
// 1. Upload the audio file
val url = repository.uploadMedia(file)
// 2. Send the message with the attachment
val attachment = chats.data.remote.api.AttachmentRequest(
type = "voice",
url = url,
fileName = file.name,
fileSize = file.length()
)
val sentMessage = repository.sendMessage(
chatId = chatId,
content = null,
type = "audio",
attachments = listOf(attachment)
)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") }
}
}
}
fun addPendingAttachment(uri: android.net.Uri, context: android.content.Context) {
viewModelScope.launch {
val mimeType = context.contentResolver.getType(uri) ?: ""
val file = if (_state.value.isCompressionEnabled && mimeType.startsWith("image")) {
ImageUtils.compressImage(context, uri)
} else {
copyUriToFile(context, uri)
}
file?.let { f ->
_state.update { it.copy(pendingAttachments = it.pendingAttachments + f) }
}
}
}
fun removePendingAttachment(file: File) {
_state.update { it.copy(pendingAttachments = it.pendingAttachments - file) }
}
fun uploadMedia(file: File) {
if (file.length() > _state.value.maxFileSize) {
_state.update { it.copy(error = "File too large") }