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