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

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
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -105,6 +105,7 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.5.0") implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif: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-svg:2.5.0")
implementation("io.coil-kt:coil-video:2.5.0")
// Security // Security
implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("androidx.security:security-crypto:1.1.0-alpha06")
@@ -18,6 +18,7 @@
<activity <activity
android:name="com.knot.messenger.MainActivity" android:name="com.knot.messenger.MainActivity"
android:exported="true" android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.KnotMessenger"> android:theme="@style/Theme.KnotMessenger">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -1,7 +1,18 @@
package com.knot.messenger package com.knot.messenger
import android.app.Application import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.VideoFrameDecoder
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp @HiltAndroidApp
class MainApplication : Application() class MainApplication : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(VideoFrameDecoder.Factory())
}
.build()
}
}
@@ -94,7 +94,15 @@ class ChatRepositoryImpl @Inject constructor(
} }
override suspend fun uploadMedia(file: java.io.File): String { 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) val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
return api.uploadFile(body).url return api.uploadFile(body).url
} }
@@ -177,9 +185,9 @@ fun MessageDto.toDomain(baseUrl: String): Message {
createdAt = createdAt ?: "", createdAt = createdAt ?: "",
media = media.map { media = media.map {
chats.domain.model.Media( chats.domain.model.Media(
id = it.id, id = it.id ?: java.util.UUID.randomUUID().toString(),
type = it.type, type = it.type ?: "unknown",
url = it.url.ensureAbsoluteUrl(baseUrl), url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
filename = it.filename, filename = it.filename,
size = it.size, size = it.size,
duration = it.duration 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 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 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( return Message(
id = id, id = id,
@@ -237,9 +261,9 @@ fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.
createdAt = createdAt, createdAt = createdAt,
media = mediaDtos.map { media = mediaDtos.map {
chats.domain.model.Media( chats.domain.model.Media(
id = it.id, id = it.id ?: java.util.UUID.randomUUID().toString(),
type = it.type, type = it.type ?: "unknown",
url = it.url.ensureAbsoluteUrl(baseUrl), url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
filename = it.filename, filename = it.filename,
size = it.size, size = it.size,
duration = it.duration duration = it.duration
@@ -11,7 +11,14 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* 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.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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -30,6 +37,15 @@ import core.utils.copyUriToFile
import java.io.File import java.io.File
import ru.knot.messager.R import ru.knot.messager.R
import kotlinx.coroutines.launch 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) @OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
@@ -47,6 +63,18 @@ fun ChatDetailScreen(
var textInput by remember { mutableStateOf("") } var textInput by remember { mutableStateOf("") }
var isEmojiPickerVisible by remember { mutableStateOf(false) } var isEmojiPickerVisible by remember { mutableStateOf(false) }
var isRecording 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 keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@@ -80,14 +108,11 @@ fun ChatDetailScreen(
} }
} }
// Пикер галереи // Пикер галереи (Мультивыбор)
val galleryLauncher = rememberLauncherForActivityResult( val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.PickMultipleVisualMedia()
) { uri: Uri? -> ) { uris ->
uri?.let { uris.forEach { viewModel.addPendingAttachment(it, context) }
val file = copyUriToFile(context, it)
file?.let { viewModel.uploadMedia(it) }
}
} }
// Загрузка данных чата при входе // Загрузка данных чата при входе
@@ -179,11 +204,7 @@ fun ChatDetailScreen(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
val allChatMedia = state.messages.flatMap { msg -> val allChatMedia = state.messages.flatMap { msg ->
msg.media.filter { msg.media.map { it to msg.id }
it.type.startsWith("image") ||
it.type.startsWith("video") ||
it.filename?.endsWith(".gif", true) == true
}.map { it to msg.id }
}.reversed() }.reversed()
items(state.messages, key = { it.id }) { message -> items(state.messages, key = { it.id }) { message ->
@@ -195,9 +216,23 @@ fun ChatDetailScreen(
onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) }, onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) },
onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) }, onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) },
onMediaClick = { clickedMedia -> onMediaClick = { clickedMedia ->
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url } val isMedia = clickedMedia.type.startsWith("image") ||
selectedMediaList = allChatMedia.map { it.first } clickedMedia.type.startsWith("video") ||
initialMediaIndex = if (initialIndex != -1) initialIndex else 0 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp), .padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton( // 1. Скрепка (слева)
onClick = { IconButton(onClick = {
isEmojiPickerVisible = !isEmojiPickerVisible galleryLauncher.launch(
if (isEmojiPickerVisible) { androidx.activity.result.PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
keyboardController?.hide()
focusManager.clearFocus()
}
}
) {
Icon(
Icons.Default.EmojiEmotions,
contentDescription = stringResource(R.string.emoji),
tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray
) )
} }) {
IconButton(onClick = { galleryLauncher.launch("*/*") }) { Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach), tint = Color.Gray)
Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach))
} }
TextField( // 2. Поле ввода + Эмодзи (в одном баббле)
value = textInput, Box(
onValueChange = { textInput = it },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.onFocusChanged { .clip(RoundedCornerShape(24.dp))
if (it.isFocused) { .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f))
isEmojiPickerVisible = false .padding(horizontal = 4.dp, vertical = 4.dp)
} ) {
}, Row(verticalAlignment = Alignment.CenterVertically) {
placeholder = { Text(stringResource(R.string.message_placeholder)) }, IconButton(
maxLines = 4, onClick = {
colors = TextFieldDefaults.colors( isEmojiPickerVisible = !isEmojiPickerVisible
focusedIndicatorColor = Color.Transparent, if (isEmojiPickerVisible) {
unfocusedIndicatorColor = Color.Transparent keyboardController?.hide()
) focusManager.clearFocus()
) }
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
} }
) {
Icon(
Icons.Default.EmojiEmotions,
contentDescription = stringResource(R.string.emoji),
tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray,
modifier = Modifier.size(24.dp)
)
} }
) {
Icon( BasicTextField(
if (isRecording) Icons.Default.Stop else Icons.Default.Mic, value = textInput,
contentDescription = stringResource(R.string.voice_message), onValueChange = { textInput = it },
tint = if (isRecording) Color.Red else Color.Gray 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) Spacer(modifier = Modifier.width(8.dp))
textInput = ""
}) { // 3. Кнопка действия (в синем квадрате)
Icon(Icons.Default.Send, contentDescription = stringResource(R.string.send)) 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) { if (isEmojiPickerVisible) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadTrendingGifs() viewModel.loadTrendingGifs()
@@ -309,16 +554,15 @@ fun ChatDetailScreen(
viewModel.loadGifCategories() 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 kotlinx.coroutines.launch
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import core.utils.copyUriToFile
import core.utils.ImageUtils
import chats.data.remote.api.KlipyGifDto import chats.data.remote.api.KlipyGifDto
@@ -34,7 +36,10 @@ data class ChatDetailState(
val recentGifs: List<KlipyGifDto> = emptyList(), val recentGifs: List<KlipyGifDto> = emptyList(),
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(), val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
val isGifsLoading: Boolean = false, 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 @HiltViewModel
@@ -42,7 +47,8 @@ class ChatDetailViewModel @Inject constructor(
private val repository: ChatRepository, private val repository: ChatRepository,
private val signalrClient: ChatHubClient, private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig, private val serverConfig: ServerConfig,
private val tokenManager: TokenManager private val tokenManager: TokenManager,
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(ChatDetailState()) private val _state = MutableStateFlow(ChatDetailState())
@@ -52,14 +58,26 @@ class ChatDetailViewModel @Inject constructor(
private var typingTimerJob: Job? = null private var typingTimerJob: Job? = null
private var lastTypingSentTime: Long = 0 private var lastTypingSentTime: Long = 0
private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE)
init { init {
val config = serverConfig.getServerConfig() val config = serverConfig.getServerConfig()
val savedCompression = prefs.getBoolean("compression_enabled", true)
_state.update { it.copy( _state.update { it.copy(
canCall = config.features.calls, 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 { fun getCurrentUserId(): String {
return tokenManager.getUserId() ?: "" return tokenManager.getUserId() ?: ""
} }
@@ -181,18 +199,27 @@ class ChatDetailViewModel @Inject constructor(
} }
fun sendMessage(text: String) { fun sendMessage(text: String) {
val chatId = currentChatId ?: return 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 tempId = "temp_${System.currentTimeMillis()}"
val userId = getCurrentUserId() 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( val tempMessage = Message(
id = tempId, id = tempId,
chatId = chatId, chatId = chatId,
senderId = userId, senderId = userId,
content = text, content = if (text.isBlank()) null else text,
createdAt = java.util.Date().toString(), createdAt = java.util.Date().toString(),
mediaType = chats.domain.model.MediaType.TEXT, mediaType = mediaType,
media = emptyList(), media = emptyList(),
senderName = "Вы", senderName = "Вы",
senderAvatar = null, senderAvatar = null,
@@ -207,12 +234,40 @@ class ChatDetailViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
try { 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.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage) repository.saveMessage(sentMessage)
} catch (e: Exception) { } catch (e: Exception) {
repository.deleteLocalMessage(tempId) 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) { fun uploadMedia(file: File) {
if (file.length() > _state.value.maxFileSize) { if (file.length() > _state.value.maxFileSize) {
_state.update { it.copy(error = "File too large") } _state.update { it.copy(error = "File too large") }
@@ -69,8 +69,7 @@ fun MessageBubble(
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp) RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
} }
val isVoiceMessage = message.mediaType == MediaType.AUDIO && val isVoiceMessage = message.media.any { it.type == "voice" } || (message.mediaType == MediaType.AUDIO && (message.content == null || message.content.isEmpty()))
(message.content == null || message.content.isEmpty())
Row( Row(
modifier = Modifier modifier = Modifier
@@ -225,7 +224,8 @@ fun MessageBubble(
} else { } else {
// Multi-media grid // Multi-media grid
PhotoGrid( PhotoGrid(
message.media, mediaList = message.media,
isCurrentUser = isCurrentUser,
onMediaClick = { index -> onMediaClick(message.media[index]) }, onMediaClick = { index -> onMediaClick(message.media[index]) },
modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp)) modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp))
) )
@@ -365,30 +365,88 @@ fun FileItem(media: Media, isCurrentUser: Boolean, contentColor: Color) {
} }
@Composable @Composable
fun PhotoGrid(media: List<Media>, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) { fun PhotoGrid(mediaList: List<Media>, isCurrentUser: Boolean, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
val items = media.take(4) if (mediaList.isEmpty()) return
val columns = when {
mediaList.size == 1 -> 1
mediaList.size % 3 == 0 -> 3
else -> 2
}
Column(modifier = modifier) { Column(modifier = modifier) {
val rows = (items.size + 1) / 2 val rows = (mediaList.size + columns - 1) / columns
for (i in 0 until rows) { for (i in 0 until rows) {
Row(modifier = Modifier.weight(1f)) { Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
val firstIndex = i * 2 for (j in 0 until columns) {
AsyncImage( val index = i * columns + j
model = items[firstIndex].url, if (index < mediaList.size) {
contentDescription = null, GridItem(
modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex) }, media = mediaList[index],
contentScale = ContentScale.Crop modifier = Modifier
) .weight(1f)
if (firstIndex + 1 < items.size) { .fillMaxHeight()
AsyncImage( .padding(1.dp)
model = items[firstIndex + 1].url, .clickable { onMediaClick(index) }
contentDescription = null, )
modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex + 1) }, } else {
contentScale = ContentScale.Crop Spacer(modifier = Modifier.weight(1f))
) }
} else if (rows > 1) {
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
)
}
}
}
}
@@ -29,6 +29,7 @@ import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable @Composable
fun AppAudioPlayer( fun AppAudioPlayer(
@@ -43,6 +44,7 @@ fun AppAudioPlayer(
contentColor: Color = Color.White contentColor: Color = Color.White
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
var playbackSpeed by remember { mutableFloatStateOf(initialPlaybackSpeed) } var playbackSpeed by remember { mutableFloatStateOf(initialPlaybackSpeed) }
// Update global speed when changed locally // Update global speed when changed locally
@@ -259,7 +261,11 @@ fun AppAudioPlayer(
imageVector = Icons.Default.Download, imageVector = Icons.Default.Download,
contentDescription = null, contentDescription = null,
tint = contentColor.copy(alpha = 0.6f), 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)
}
}
) )
} }
} }
@@ -11,6 +11,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -28,6 +29,9 @@ import coil.compose.AsyncImage
import chats.domain.model.Media import chats.domain.model.Media
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import android.content.Intent
import android.net.Uri
import kotlinx.coroutines.launch
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
@@ -36,6 +40,8 @@ fun AppMediaLightbox(
initialIndex: Int = 0, initialIndex: Int = 0,
onClose: () -> Unit onClose: () -> Unit
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { mediaList.size }) val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { mediaList.size })
Dialog( Dialog(
@@ -70,7 +76,7 @@ fun AppMediaLightbox(
useController = true, useController = true,
autoPlay = isCurrentPage // Только если страница активна autoPlay = isCurrentPage // Только если страница активна
) )
} else { } else if (media.type.startsWith("image") || media.type.contains("gif")) {
var scale by remember { mutableFloatStateOf(1f) } var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
@@ -88,6 +94,41 @@ fun AppMediaLightbox(
), ),
contentScale = ContentScale.Fit 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( 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 modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)) .background(Color.Black.copy(alpha = 0.5f))
+90
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()
}
}
}
}
+9 -1
View File
@@ -8,8 +8,16 @@ import java.util.UUID
fun copyUriToFile(context: Context, uri: Uri): File? { fun copyUriToFile(context: Context, uri: Uri): File? {
return try { 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 inputStream = context.contentResolver.openInputStream(uri)
val file = File(context.cacheDir, "${UUID.randomUUID()}.tmp") val file = File(context.cacheDir, originalName)
val outputStream = FileOutputStream(file) val outputStream = FileOutputStream(file)
inputStream?.use { input -> inputStream?.use { input ->
outputStream.use { output -> outputStream.use { output ->
+59
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
}
}
}