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

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

View File

@@ -105,6 +105,7 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0")
implementation("io.coil-kt:coil-svg:2.5.0")
implementation("io.coil-kt:coil-video:2.5.0")
// Security
implementation("androidx.security:security-crypto:1.1.0-alpha06")

View File

@@ -18,6 +18,7 @@
<activity
android:name="com.knot.messenger.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.KnotMessenger">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -1,7 +1,18 @@
package com.knot.messenger
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.VideoFrameDecoder
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MainApplication : Application()
class MainApplication : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(VideoFrameDecoder.Factory())
}
.build()
}
}

View File

@@ -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

View File

@@ -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 }
)
}
}

View File

@@ -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") }

View File

@@ -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
)
}
}
}
}

View File

@@ -29,6 +29,7 @@ import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun AppAudioPlayer(
@@ -43,6 +44,7 @@ fun AppAudioPlayer(
contentColor: Color = Color.White
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var playbackSpeed by remember { mutableFloatStateOf(initialPlaybackSpeed) }
// Update global speed when changed locally
@@ -259,7 +261,11 @@ fun AppAudioPlayer(
imageVector = Icons.Default.Download,
contentDescription = null,
tint = contentColor.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp).clickable { /* Handle download */ }
modifier = Modifier.size(14.dp).clickable {
scope.launch {
core.utils.DownloadUtils.downloadMedia(context, url, fileName)
}
}
)
}
}

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -28,6 +29,9 @@ import coil.compose.AsyncImage
import chats.domain.model.Media
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import android.content.Intent
import android.net.Uri
import kotlinx.coroutines.launch
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
@@ -36,6 +40,8 @@ fun AppMediaLightbox(
initialIndex: Int = 0,
onClose: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { mediaList.size })
Dialog(
@@ -70,7 +76,7 @@ fun AppMediaLightbox(
useController = true,
autoPlay = isCurrentPage // Только если страница активна
)
} else {
} else if (media.type.startsWith("image") || media.type.contains("gif")) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
@@ -88,6 +94,41 @@ fun AppMediaLightbox(
),
contentScale = ContentScale.Fit
)
} else {
// Document / File placeholder in Lightbox
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.InsertDriveFile,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(100.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = media.filename ?: "File",
color = Color.White,
style = MaterialTheme.typography.headlineSmall,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(media.url))
context.startActivity(intent)
} catch (e: Exception) {
// Handle error (no app to open)
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black)
) {
Text("Открыть в приложении")
}
}
}
}
}
@@ -119,7 +160,12 @@ fun AppMediaLightbox(
}
IconButton(
onClick = { /* Handle download of mediaList[pagerState.currentPage] */ },
onClick = {
val currentMedia = mediaList[pagerState.currentPage]
scope.launch {
core.utils.DownloadUtils.downloadMedia(context, currentMedia.url, currentMedia.filename)
}
},
modifier = Modifier
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))

View File

@@ -0,0 +1,90 @@
package core.utils
import android.app.DownloadManager
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
object DownloadUtils {
suspend fun downloadMedia(context: Context, url: String, fileName: String?) {
withContext(Dispatchers.IO) {
try {
val name = fileName ?: url.substringAfterLast("/")
val extension = name.substringAfterLast(".", "jpg")
val isImage = extension in listOf("jpg", "jpeg", "png", "webp", "gif")
val isVideo = extension in listOf("mp4", "webm", "mkv", "mov")
if (isImage || isVideo) {
saveToGallery(context, url, name, isImage)
} else {
useDownloadManager(context, url, name)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, "Ошибка скачивания: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
}
}
}
}
private fun useDownloadManager(context: Context, url: String, fileName: String) {
val request = DownloadManager.Request(Uri.parse(url))
.setTitle(fileName)
.setDescription("Downloading...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
downloadManager.enqueue(request)
// Show feedback
(context as? android.app.Activity)?.runOnUiThread {
Toast.makeText(context, "Загрузка началась: $fileName", Toast.LENGTH_SHORT).show()
}
}
private suspend fun saveToGallery(context: Context, url: String, fileName: String, isImage: Boolean) {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, if (isImage) "image/jpeg" else "video/mp4")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, if (isImage) Environment.DIRECTORY_PICTURES else Environment.DIRECTORY_MOVIES)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
}
val collection = if (isImage) {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
} else {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
val uri = resolver.insert(collection, contentValues)
uri?.let {
URL(url).openStream().use { input ->
resolver.openOutputStream(it).use { output ->
input.copyTo(output!!)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(it, contentValues, null, null)
}
withContext(Dispatchers.Main) {
Toast.makeText(context, "Сохранено в галерею", Toast.LENGTH_SHORT).show()
}
}
}
}

View File

@@ -8,8 +8,16 @@ import java.util.UUID
fun copyUriToFile(context: Context, uri: Uri): File? {
return try {
val cursor = context.contentResolver.query(uri, null, null, null, null)
val originalName = cursor?.use {
if (it.moveToFirst()) {
val index = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (index != -1) it.getString(index) else null
} else null
} ?: "${UUID.randomUUID()}.tmp"
val inputStream = context.contentResolver.openInputStream(uri)
val file = File(context.cacheDir, "${UUID.randomUUID()}.tmp")
val file = File(context.cacheDir, originalName)
val outputStream = FileOutputStream(file)
inputStream?.use { input ->
outputStream.use { output ->

View File

@@ -0,0 +1,59 @@
package core.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
object ImageUtils {
fun compressImage(context: Context, uri: Uri, maxWidth: Int = 1280, maxHeight: Int = 1280, quality: Int = 80): File? {
return try {
val inputStream = context.contentResolver.openInputStream(uri)
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeStream(inputStream, null, options)
inputStream?.close()
var inSampleSize = 1
if (options.outHeight > maxHeight || options.outWidth > maxWidth) {
val halfHeight = options.outHeight / 2
val halfWidth = options.outWidth / 2
while (halfHeight / inSampleSize >= maxHeight && halfWidth / inSampleSize >= maxWidth) {
inSampleSize *= 2
}
}
val decodeOptions = BitmapFactory.Options().apply {
inSampleSize = inSampleSize
}
val finalInputStream = context.contentResolver.openInputStream(uri)
var bitmap = BitmapFactory.decodeStream(finalInputStream, null, decodeOptions)
finalInputStream?.close()
if (bitmap == null) return null
// Resize if still too large
if (bitmap.width > maxWidth || bitmap.height > maxHeight) {
val scale = Math.min(maxWidth.toFloat() / bitmap.width, maxHeight.toFloat() / bitmap.height)
val matrix = Matrix().apply { postScale(scale, scale) }
bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
val file = File(context.cacheDir, "compressed_${UUID.randomUUID()}.jpg")
val out = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
out.flush()
out.close()
file
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}