Чат, вложения
This commit is contained in:
@@ -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") }
|
||||
|
||||
Reference in New Issue
Block a user