Чат, вложения
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-gif:2.5.0")
|
||||
implementation("io.coil-kt:coil-svg:2.5.0")
|
||||
implementation("io.coil-kt:coil-video:2.5.0")
|
||||
|
||||
// Security
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<activity
|
||||
android:name="com.knot.messenger.MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.KnotMessenger">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
package com.knot.messenger
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.VideoFrameDecoder
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class MainApplication : Application()
|
||||
class MainApplication : Application(), ImageLoaderFactory {
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.components {
|
||||
add(VideoFrameDecoder.Factory())
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,15 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun uploadMedia(file: java.io.File): String {
|
||||
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
|
||||
val mimeType = when (file.extension.lowercase()) {
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"png" -> "image/png"
|
||||
"webp" -> "image/webp"
|
||||
"mp4" -> "video/mp4"
|
||||
"mp3", "m4a", "wav" -> "audio/mpeg"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull())
|
||||
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
|
||||
return api.uploadFile(body).url
|
||||
}
|
||||
@@ -177,9 +185,9 @@ fun MessageDto.toDomain(baseUrl: String): Message {
|
||||
createdAt = createdAt ?: "",
|
||||
media = media.map {
|
||||
chats.domain.model.Media(
|
||||
id = it.id,
|
||||
type = it.type,
|
||||
url = it.url.ensureAbsoluteUrl(baseUrl),
|
||||
id = it.id ?: java.util.UUID.randomUUID().toString(),
|
||||
type = it.type ?: "unknown",
|
||||
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
|
||||
filename = it.filename,
|
||||
size = it.size,
|
||||
duration = it.duration
|
||||
@@ -221,10 +229,26 @@ fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.
|
||||
}
|
||||
|
||||
val mediaTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.MediaItemDto>>() {}.type
|
||||
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = gson.fromJson(mediaJson, mediaTypeToken) ?: emptyList()
|
||||
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = try {
|
||||
gson.fromJson(mediaJson, mediaTypeToken)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
} ?: emptyList()
|
||||
|
||||
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
|
||||
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = gson.fromJson(reactionsJson, reactionsTypeToken) ?: emptyList()
|
||||
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = try {
|
||||
// Try to parse as array (List<ReactionDto>)
|
||||
gson.fromJson(reactionsJson, reactionsTypeToken)
|
||||
} catch (e: Exception) {
|
||||
// If it's an object instead of array, parse as map and convert to list
|
||||
try {
|
||||
val mapType = object : com.google.gson.reflect.TypeToken<Map<String, Int>>() {}.type
|
||||
val map: Map<String, Int> = gson.fromJson(reactionsJson, mapType) ?: emptyMap()
|
||||
map.map { chats.data.remote.dto.ReactionDto(it.key, it.value, false) }
|
||||
} catch (innerE: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
return Message(
|
||||
id = id,
|
||||
@@ -237,9 +261,9 @@ fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.
|
||||
createdAt = createdAt,
|
||||
media = mediaDtos.map {
|
||||
chats.domain.model.Media(
|
||||
id = it.id,
|
||||
type = it.type,
|
||||
url = it.url.ensureAbsoluteUrl(baseUrl),
|
||||
id = it.id ?: java.util.UUID.randomUUID().toString(),
|
||||
type = it.type ?: "unknown",
|
||||
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
|
||||
filename = it.filename,
|
||||
size = it.size,
|
||||
duration = it.duration
|
||||
|
||||
@@ -11,7 +11,14 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -30,6 +37,15 @@ import core.utils.copyUriToFile
|
||||
import java.io.File
|
||||
import ru.knot.messager.R
|
||||
import kotlinx.coroutines.launch
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -47,6 +63,18 @@ fun ChatDetailScreen(
|
||||
var textInput by remember { mutableStateOf("") }
|
||||
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
var recordingOffset by remember { mutableFloatStateOf(0f) }
|
||||
var isRecordingCanceled by remember { mutableStateOf(false) }
|
||||
var currentRecordingFile by remember { mutableStateOf<File?>(null) }
|
||||
val micPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
|
||||
voiceRecorder.startRecording(file)
|
||||
isRecording = true
|
||||
}
|
||||
}
|
||||
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -80,14 +108,11 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Пикер галереи
|
||||
// Пикер галереи (Мультивыбор)
|
||||
val galleryLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
val file = copyUriToFile(context, it)
|
||||
file?.let { viewModel.uploadMedia(it) }
|
||||
}
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia()
|
||||
) { uris ->
|
||||
uris.forEach { viewModel.addPendingAttachment(it, context) }
|
||||
}
|
||||
|
||||
// Загрузка данных чата при входе
|
||||
@@ -179,11 +204,7 @@ fun ChatDetailScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val allChatMedia = state.messages.flatMap { msg ->
|
||||
msg.media.filter {
|
||||
it.type.startsWith("image") ||
|
||||
it.type.startsWith("video") ||
|
||||
it.filename?.endsWith(".gif", true) == true
|
||||
}.map { it to msg.id }
|
||||
msg.media.map { it to msg.id }
|
||||
}.reversed()
|
||||
|
||||
items(state.messages, key = { it.id }) { message ->
|
||||
@@ -195,9 +216,23 @@ fun ChatDetailScreen(
|
||||
onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) },
|
||||
onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) },
|
||||
onMediaClick = { clickedMedia ->
|
||||
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url }
|
||||
selectedMediaList = allChatMedia.map { it.first }
|
||||
initialMediaIndex = if (initialIndex != -1) initialIndex else 0
|
||||
val isMedia = clickedMedia.type.startsWith("image") ||
|
||||
clickedMedia.type.startsWith("video") ||
|
||||
clickedMedia.type.contains("gif")
|
||||
|
||||
if (isMedia) {
|
||||
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url }
|
||||
selectedMediaList = allChatMedia.map { it.first }
|
||||
initialMediaIndex = if (initialIndex != -1) initialIndex else 0
|
||||
} else {
|
||||
// Прямое открытие документов во внешних приложениях
|
||||
try {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url))
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
// Ошибка (нет софта)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -210,79 +245,289 @@ fun ChatDetailScreen(
|
||||
}
|
||||
|
||||
// Панель ввода
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
) {
|
||||
// Pending Attachments Bar
|
||||
if (state.pendingAttachments.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Вложения (${state.pendingAttachments.size})",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// HQ Toggle moved here
|
||||
if (state.pendingAttachments.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp") }) {
|
||||
TextButton(
|
||||
onClick = { viewModel.toggleCompression() },
|
||||
modifier = Modifier.height(30.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (!state.isCompressionEnabled) Icons.Default.HighQuality else Icons.Default.Hd,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (!state.isCompressionEnabled) MaterialTheme.colorScheme.primary else Color.Gray
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = if (!state.isCompressionEnabled) "Без сжатия" else "Сжать (HD)",
|
||||
fontSize = 11.sp,
|
||||
color = if (!state.isCompressionEnabled) MaterialTheme.colorScheme.primary else Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(state.pendingAttachments) { file ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
val isImage = file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif")
|
||||
val isVideo = file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm")
|
||||
|
||||
if (isImage || isVideo) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = rememberAsyncImagePainter(file),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
)
|
||||
if (isVideo) {
|
||||
Icon(
|
||||
Icons.Default.PlayCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.Center).size(32.dp),
|
||||
tint = Color.White.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(
|
||||
text = file.extension.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка закрытия (Centered properly)
|
||||
Surface(
|
||||
onClick = { viewModel.removePendingAttachment(file) },
|
||||
color = Color.Black.copy(alpha = 0.5f),
|
||||
shape = CircleShape,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(20.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isUploading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp).padding(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
isEmojiPickerVisible = !isEmojiPickerVisible
|
||||
if (isEmojiPickerVisible) {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.EmojiEmotions,
|
||||
contentDescription = stringResource(R.string.emoji),
|
||||
tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray
|
||||
// 1. Скрепка (слева)
|
||||
IconButton(onClick = {
|
||||
galleryLauncher.launch(
|
||||
androidx.activity.result.PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { galleryLauncher.launch("*/*") }) {
|
||||
Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach))
|
||||
}) {
|
||||
Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach), tint = Color.Gray)
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = textInput,
|
||||
onValueChange = { textInput = it },
|
||||
// 2. Поле ввода + Эмодзи (в одном баббле)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
isEmojiPickerVisible = false
|
||||
}
|
||||
},
|
||||
placeholder = { Text(stringResource(R.string.message_placeholder)) },
|
||||
maxLines = 4,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
|
||||
if (textInput.isBlank()) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (!isRecording) {
|
||||
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
|
||||
voiceRecorder.startRecording(file)
|
||||
isRecording = true
|
||||
} else {
|
||||
voiceRecorder.stopRecording()
|
||||
isRecording = false
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f))
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
isEmojiPickerVisible = !isEmojiPickerVisible
|
||||
if (isEmojiPickerVisible) {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.EmojiEmotions,
|
||||
contentDescription = stringResource(R.string.emoji),
|
||||
tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
if (isRecording) Icons.Default.Stop else Icons.Default.Mic,
|
||||
contentDescription = stringResource(R.string.voice_message),
|
||||
tint = if (isRecording) Color.Red else Color.Gray
|
||||
|
||||
BasicTextField(
|
||||
value = textInput,
|
||||
onValueChange = { textInput = it },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
isEmojiPickerVisible = false
|
||||
}
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
decorationBox = { innerTextField ->
|
||||
if (textInput.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.message_placeholder),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = {
|
||||
viewModel.sendMessage(textInput)
|
||||
textInput = ""
|
||||
}) {
|
||||
Icon(Icons.Default.Send, contentDescription = stringResource(R.string.send))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// 3. Кнопка действия (в синем квадрате)
|
||||
val showMic = textInput.isEmpty() && state.pendingAttachments.isEmpty()
|
||||
|
||||
Surface(
|
||||
onClick = {
|
||||
if (!showMic) {
|
||||
viewModel.sendMessage(textInput)
|
||||
textInput = ""
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (showMic) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) else MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.pointerInput(Unit) {
|
||||
if (showMic) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = {
|
||||
val hasMic = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.RECORD_AUDIO
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (hasMic) {
|
||||
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
|
||||
currentRecordingFile = file
|
||||
voiceRecorder.startRecording(file)
|
||||
isRecording = true
|
||||
isRecordingCanceled = false
|
||||
recordingOffset = 0f
|
||||
} else {
|
||||
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
recordingOffset += dragAmount.x
|
||||
if (recordingOffset < -200f) {
|
||||
isRecordingCanceled = true
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
if (isRecording) {
|
||||
voiceRecorder.stopRecording()
|
||||
if (!isRecordingCanceled) {
|
||||
currentRecordingFile?.let { viewModel.sendVoiceMessage(it) }
|
||||
} else {
|
||||
currentRecordingFile?.delete()
|
||||
}
|
||||
isRecording = false
|
||||
recordingOffset = 0f
|
||||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
if (isRecording) {
|
||||
voiceRecorder.stopRecording()
|
||||
currentRecordingFile?.delete()
|
||||
isRecording = false
|
||||
recordingOffset = 0f
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (isRecording) {
|
||||
// Overlay for recording state?
|
||||
// For now just use the color and icon
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (showMic) (if (isRecording) Icons.Default.Mic else Icons.Default.MicNone) else Icons.Default.Send,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay text for recording cancellation (absolute positioning might be better but let's see)
|
||||
if (isRecording) {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||
Text(
|
||||
text = if (isRecordingCanceled) "Отменено" else "← Смахните для отмены",
|
||||
color = if (isRecordingCanceled) Color.Red else Color.Gray,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmojiPickerVisible) {
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadTrendingGifs()
|
||||
@@ -309,16 +554,15 @@ fun ChatDetailScreen(
|
||||
viewModel.loadGifCategories()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Просмотрщик медиа
|
||||
selectedMediaList?.let { list ->
|
||||
AppMediaLightbox(
|
||||
mediaList = list,
|
||||
initialIndex = initialMediaIndex,
|
||||
onClose = { selectedMediaList = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Просмотрщик медиа
|
||||
selectedMediaList?.let { list ->
|
||||
AppMediaLightbox(
|
||||
mediaList = list,
|
||||
initialIndex = initialMediaIndex,
|
||||
onClose = { selectedMediaList = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import core.utils.copyUriToFile
|
||||
import core.utils.ImageUtils
|
||||
|
||||
import chats.data.remote.api.KlipyGifDto
|
||||
|
||||
@@ -34,7 +36,10 @@ data class ChatDetailState(
|
||||
val recentGifs: List<KlipyGifDto> = emptyList(),
|
||||
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
|
||||
val isGifsLoading: Boolean = false,
|
||||
val initialScrollIndex: Int? = null
|
||||
val initialScrollIndex: Int? = null,
|
||||
val pendingAttachments: List<File> = emptyList(),
|
||||
val isUploading: Boolean = false,
|
||||
val isCompressionEnabled: Boolean = true
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -42,7 +47,8 @@ class ChatDetailViewModel @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
private val signalrClient: ChatHubClient,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val tokenManager: TokenManager
|
||||
private val tokenManager: TokenManager,
|
||||
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(ChatDetailState())
|
||||
@@ -52,14 +58,26 @@ class ChatDetailViewModel @Inject constructor(
|
||||
private var typingTimerJob: Job? = null
|
||||
private var lastTypingSentTime: Long = 0
|
||||
|
||||
private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE)
|
||||
|
||||
init {
|
||||
val config = serverConfig.getServerConfig()
|
||||
val savedCompression = prefs.getBoolean("compression_enabled", true)
|
||||
_state.update { it.copy(
|
||||
canCall = config.features.calls,
|
||||
maxFileSize = config.limits.maxFileSize
|
||||
maxFileSize = config.limits.maxFileSize,
|
||||
isCompressionEnabled = savedCompression
|
||||
) }
|
||||
}
|
||||
|
||||
fun toggleCompression() {
|
||||
_state.update { currentState ->
|
||||
val newValue = !currentState.isCompressionEnabled
|
||||
prefs.edit().putBoolean("compression_enabled", newValue).apply()
|
||||
currentState.copy(isCompressionEnabled = newValue)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentUserId(): String {
|
||||
return tokenManager.getUserId() ?: ""
|
||||
}
|
||||
@@ -181,18 +199,27 @@ class ChatDetailViewModel @Inject constructor(
|
||||
}
|
||||
fun sendMessage(text: String) {
|
||||
val chatId = currentChatId ?: return
|
||||
if (text.isBlank()) return
|
||||
val pending = _state.value.pendingAttachments
|
||||
if (text.isBlank() && pending.isEmpty()) return
|
||||
|
||||
val tempId = "temp_${System.currentTimeMillis()}"
|
||||
val userId = getCurrentUserId()
|
||||
|
||||
// Determine mediaType based on attachments
|
||||
val mediaType = when {
|
||||
pending.isEmpty() -> chats.domain.model.MediaType.TEXT
|
||||
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> chats.domain.model.MediaType.IMAGE
|
||||
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> chats.domain.model.MediaType.VIDEO
|
||||
else -> chats.domain.model.MediaType.TEXT
|
||||
}
|
||||
|
||||
val tempMessage = Message(
|
||||
id = tempId,
|
||||
chatId = chatId,
|
||||
senderId = userId,
|
||||
content = text,
|
||||
content = if (text.isBlank()) null else text,
|
||||
createdAt = java.util.Date().toString(),
|
||||
mediaType = chats.domain.model.MediaType.TEXT,
|
||||
mediaType = mediaType,
|
||||
media = emptyList(),
|
||||
senderName = "Вы",
|
||||
senderAvatar = null,
|
||||
@@ -207,12 +234,40 @@ class ChatDetailViewModel @Inject constructor(
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val sentMessage = repository.sendMessage(chatId, text)
|
||||
// Upload attachments if any
|
||||
val attachmentRequests = if (_state.value.pendingAttachments.isNotEmpty()) {
|
||||
_state.update { it.copy(isUploading = true) }
|
||||
val requests = _state.value.pendingAttachments.map { file ->
|
||||
val url = repository.uploadMedia(file)
|
||||
chats.data.remote.api.AttachmentRequest(
|
||||
type = when {
|
||||
file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") -> "image"
|
||||
file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") -> "video"
|
||||
file.extension.lowercase() in listOf("mp3", "m4a", "wav", "aac", "ogg") -> "audio"
|
||||
else -> "file"
|
||||
},
|
||||
url = url,
|
||||
fileName = file.name,
|
||||
fileSize = file.length()
|
||||
)
|
||||
}
|
||||
_state.update { it.copy(isUploading = false, pendingAttachments = emptyList()) }
|
||||
requests
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val sentMessage = repository.sendMessage(
|
||||
chatId = chatId,
|
||||
content = text,
|
||||
type = if (attachmentRequests != null) "media" else "text",
|
||||
attachments = attachmentRequests
|
||||
)
|
||||
repository.deleteLocalMessage(tempId)
|
||||
repository.saveMessage(sentMessage)
|
||||
} catch (e: Exception) {
|
||||
repository.deleteLocalMessage(tempId)
|
||||
_state.update { it.copy(error = e.localizedMessage) }
|
||||
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,6 +282,77 @@ class ChatDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVoiceMessage(file: File) {
|
||||
val chatId = currentChatId ?: return
|
||||
val tempId = "temp_voice_${System.currentTimeMillis()}"
|
||||
val userId = getCurrentUserId()
|
||||
|
||||
val tempMessage = Message(
|
||||
id = tempId,
|
||||
chatId = chatId,
|
||||
senderId = userId,
|
||||
content = null,
|
||||
createdAt = java.util.Date().toString(),
|
||||
mediaType = chats.domain.model.MediaType.AUDIO,
|
||||
media = emptyList(),
|
||||
senderName = "Вы",
|
||||
reactions = emptyMap(),
|
||||
isRead = false,
|
||||
sequenceId = 0
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
repository.saveMessage(tempMessage)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// 1. Upload the audio file
|
||||
val url = repository.uploadMedia(file)
|
||||
|
||||
// 2. Send the message with the attachment
|
||||
val attachment = chats.data.remote.api.AttachmentRequest(
|
||||
type = "voice",
|
||||
url = url,
|
||||
fileName = file.name,
|
||||
fileSize = file.length()
|
||||
)
|
||||
|
||||
val sentMessage = repository.sendMessage(
|
||||
chatId = chatId,
|
||||
content = null,
|
||||
type = "audio",
|
||||
attachments = listOf(attachment)
|
||||
)
|
||||
|
||||
repository.deleteLocalMessage(tempId)
|
||||
repository.saveMessage(sentMessage)
|
||||
} catch (e: Exception) {
|
||||
repository.deleteLocalMessage(tempId)
|
||||
_state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addPendingAttachment(uri: android.net.Uri, context: android.content.Context) {
|
||||
viewModelScope.launch {
|
||||
val mimeType = context.contentResolver.getType(uri) ?: ""
|
||||
val file = if (_state.value.isCompressionEnabled && mimeType.startsWith("image")) {
|
||||
ImageUtils.compressImage(context, uri)
|
||||
} else {
|
||||
copyUriToFile(context, uri)
|
||||
}
|
||||
|
||||
file?.let { f ->
|
||||
_state.update { it.copy(pendingAttachments = it.pendingAttachments + f) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removePendingAttachment(file: File) {
|
||||
_state.update { it.copy(pendingAttachments = it.pendingAttachments - file) }
|
||||
}
|
||||
|
||||
fun uploadMedia(file: File) {
|
||||
if (file.length() > _state.value.maxFileSize) {
|
||||
_state.update { it.copy(error = "File too large") }
|
||||
|
||||
@@ -69,8 +69,7 @@ fun MessageBubble(
|
||||
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
|
||||
}
|
||||
|
||||
val isVoiceMessage = message.mediaType == MediaType.AUDIO &&
|
||||
(message.content == null || message.content.isEmpty())
|
||||
val isVoiceMessage = message.media.any { it.type == "voice" } || (message.mediaType == MediaType.AUDIO && (message.content == null || message.content.isEmpty()))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -225,7 +224,8 @@ fun MessageBubble(
|
||||
} else {
|
||||
// Multi-media grid
|
||||
PhotoGrid(
|
||||
message.media,
|
||||
mediaList = message.media,
|
||||
isCurrentUser = isCurrentUser,
|
||||
onMediaClick = { index -> onMediaClick(message.media[index]) },
|
||||
modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
@@ -365,30 +365,88 @@ fun FileItem(media: Media, isCurrentUser: Boolean, contentColor: Color) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PhotoGrid(media: List<Media>, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
val items = media.take(4)
|
||||
fun PhotoGrid(mediaList: List<Media>, isCurrentUser: Boolean, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
if (mediaList.isEmpty()) return
|
||||
|
||||
val columns = when {
|
||||
mediaList.size == 1 -> 1
|
||||
mediaList.size % 3 == 0 -> 3
|
||||
else -> 2
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
val rows = (items.size + 1) / 2
|
||||
val rows = (mediaList.size + columns - 1) / columns
|
||||
for (i in 0 until rows) {
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
val firstIndex = i * 2
|
||||
AsyncImage(
|
||||
model = items[firstIndex].url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex) },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
if (firstIndex + 1 < items.size) {
|
||||
AsyncImage(
|
||||
model = items[firstIndex + 1].url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex + 1) },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else if (rows > 1) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
for (j in 0 until columns) {
|
||||
val index = i * columns + j
|
||||
if (index < mediaList.size) {
|
||||
GridItem(
|
||||
media = mediaList[index],
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(1.dp)
|
||||
.clickable { onMediaClick(index) }
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GridItem(media: Media, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val isImage = media.type.startsWith("image")
|
||||
val isVideo = media.type.startsWith("video")
|
||||
|
||||
Box(modifier = modifier.background(Color.White.copy(alpha = 0.1f))) {
|
||||
if (isImage || isVideo) {
|
||||
AsyncImage(
|
||||
model = media.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
if (isVideo) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayCircle,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.8f),
|
||||
modifier = Modifier.align(Alignment.Center).size(32.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(4.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(
|
||||
text = media.filename ?: "File",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = media.url.substringAfterLast(".").uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
fontSize = 8.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AppAudioPlayer(
|
||||
@@ -43,6 +44,7 @@ fun AppAudioPlayer(
|
||||
contentColor: Color = Color.White
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var playbackSpeed by remember { mutableFloatStateOf(initialPlaybackSpeed) }
|
||||
|
||||
// Update global speed when changed locally
|
||||
@@ -259,7 +261,11 @@ fun AppAudioPlayer(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
tint = contentColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(14.dp).clickable { /* Handle download */ }
|
||||
modifier = Modifier.size(14.dp).clickable {
|
||||
scope.launch {
|
||||
core.utils.DownloadUtils.downloadMedia(context, url, fileName)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -28,6 +29,9 @@ import coil.compose.AsyncImage
|
||||
import chats.domain.model.Media
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -36,6 +40,8 @@ fun AppMediaLightbox(
|
||||
initialIndex: Int = 0,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { mediaList.size })
|
||||
|
||||
Dialog(
|
||||
@@ -70,7 +76,7 @@ fun AppMediaLightbox(
|
||||
useController = true,
|
||||
autoPlay = isCurrentPage // Только если страница активна
|
||||
)
|
||||
} else {
|
||||
} else if (media.type.startsWith("image") || media.type.contains("gif")) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||
|
||||
@@ -88,6 +94,41 @@ fun AppMediaLightbox(
|
||||
),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
} else {
|
||||
// Document / File placeholder in Lightbox
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(100.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = media.filename ?: "File",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(media.url))
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
// Handle error (no app to open)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black)
|
||||
) {
|
||||
Text("Открыть в приложении")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,7 +160,12 @@ fun AppMediaLightbox(
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { /* Handle download of mediaList[pagerState.currentPage] */ },
|
||||
onClick = {
|
||||
val currentMedia = mediaList[pagerState.currentPage]
|
||||
scope.launch {
|
||||
core.utils.DownloadUtils.downloadMedia(context, currentMedia.url, currentMedia.filename)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
|
||||
90
client-mobile/core/utils/DownloadUtils.kt
Normal file
90
client-mobile/core/utils/DownloadUtils.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,16 @@ import java.util.UUID
|
||||
|
||||
fun copyUriToFile(context: Context, uri: Uri): File? {
|
||||
return try {
|
||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||
val originalName = cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val index = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (index != -1) it.getString(index) else null
|
||||
} else null
|
||||
} ?: "${UUID.randomUUID()}.tmp"
|
||||
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val file = File(context.cacheDir, "${UUID.randomUUID()}.tmp")
|
||||
val file = File(context.cacheDir, originalName)
|
||||
val outputStream = FileOutputStream(file)
|
||||
inputStream?.use { input ->
|
||||
outputStream.use { output ->
|
||||
|
||||
59
client-mobile/core/utils/ImageUtils.kt
Normal file
59
client-mobile/core/utils/ImageUtils.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user