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

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
@@ -94,7 +94,15 @@ class ChatRepositoryImpl @Inject constructor(
}
override suspend fun uploadMedia(file: java.io.File): String {
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
val mimeType = when (file.extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"webp" -> "image/webp"
"mp4" -> "video/mp4"
"mp3", "m4a", "wav" -> "audio/mpeg"
else -> "application/octet-stream"
}
val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
return api.uploadFile(body).url
}
@@ -177,9 +185,9 @@ fun MessageDto.toDomain(baseUrl: String): Message {
createdAt = createdAt ?: "",
media = media.map {
chats.domain.model.Media(
id = it.id,
type = it.type,
url = it.url.ensureAbsoluteUrl(baseUrl),
id = it.id ?: java.util.UUID.randomUUID().toString(),
type = it.type ?: "unknown",
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
filename = it.filename,
size = it.size,
duration = it.duration
@@ -221,10 +229,26 @@ fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.
}
val mediaTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.MediaItemDto>>() {}.type
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = gson.fromJson(mediaJson, mediaTypeToken) ?: emptyList()
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = try {
gson.fromJson(mediaJson, mediaTypeToken)
} catch (e: Exception) {
emptyList()
} ?: emptyList()
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = gson.fromJson(reactionsJson, reactionsTypeToken) ?: emptyList()
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = try {
// Try to parse as array (List<ReactionDto>)
gson.fromJson(reactionsJson, reactionsTypeToken)
} catch (e: Exception) {
// If it's an object instead of array, parse as map and convert to list
try {
val mapType = object : com.google.gson.reflect.TypeToken<Map<String, Int>>() {}.type
val map: Map<String, Int> = gson.fromJson(reactionsJson, mapType) ?: emptyMap()
map.map { chats.data.remote.dto.ReactionDto(it.key, it.value, false) }
} catch (innerE: Exception) {
emptyList()
}
} ?: emptyList()
return Message(
id = id,
@@ -237,9 +261,9 @@ fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.
createdAt = createdAt,
media = mediaDtos.map {
chats.domain.model.Media(
id = it.id,
type = it.type,
url = it.url.ensureAbsoluteUrl(baseUrl),
id = it.id ?: java.util.UUID.randomUUID().toString(),
type = it.type ?: "unknown",
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
filename = it.filename,
size = it.size,
duration = it.duration
@@ -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") }
@@ -69,8 +69,7 @@ fun MessageBubble(
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
}
val isVoiceMessage = message.mediaType == MediaType.AUDIO &&
(message.content == null || message.content.isEmpty())
val isVoiceMessage = message.media.any { it.type == "voice" } || (message.mediaType == MediaType.AUDIO && (message.content == null || message.content.isEmpty()))
Row(
modifier = Modifier
@@ -225,7 +224,8 @@ fun MessageBubble(
} else {
// Multi-media grid
PhotoGrid(
message.media,
mediaList = message.media,
isCurrentUser = isCurrentUser,
onMediaClick = { index -> onMediaClick(message.media[index]) },
modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp))
)
@@ -365,30 +365,88 @@ fun FileItem(media: Media, isCurrentUser: Boolean, contentColor: Color) {
}
@Composable
fun PhotoGrid(media: List<Media>, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
val items = media.take(4)
fun PhotoGrid(mediaList: List<Media>, isCurrentUser: Boolean, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
if (mediaList.isEmpty()) return
val columns = when {
mediaList.size == 1 -> 1
mediaList.size % 3 == 0 -> 3
else -> 2
}
Column(modifier = modifier) {
val rows = (items.size + 1) / 2
val rows = (mediaList.size + columns - 1) / columns
for (i in 0 until rows) {
Row(modifier = Modifier.weight(1f)) {
val firstIndex = i * 2
AsyncImage(
model = items[firstIndex].url,
contentDescription = null,
modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex) },
contentScale = ContentScale.Crop
)
if (firstIndex + 1 < items.size) {
AsyncImage(
model = items[firstIndex + 1].url,
contentDescription = null,
modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex + 1) },
contentScale = ContentScale.Crop
)
} else if (rows > 1) {
Spacer(modifier = Modifier.weight(1f))
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
for (j in 0 until columns) {
val index = i * columns + j
if (index < mediaList.size) {
GridItem(
media = mediaList[index],
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
.clickable { onMediaClick(index) }
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
@Composable
fun GridItem(media: Media, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isImage = media.type.startsWith("image")
val isVideo = media.type.startsWith("video")
Box(modifier = modifier.background(Color.White.copy(alpha = 0.1f))) {
if (isImage || isVideo) {
AsyncImage(
model = media.url,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
if (isVideo) {
Icon(
imageVector = Icons.Default.PlayCircle,
contentDescription = null,
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.align(Alignment.Center).size(32.dp)
)
}
} else {
Column(
modifier = Modifier.fillMaxSize().padding(4.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.InsertDriveFile,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(32.dp)
)
Text(
text = media.filename ?: "File",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.9f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Text(
text = media.url.substringAfterLast(".").uppercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.5f),
fontSize = 8.sp
)
}
}
}
}