diff --git a/client-mobile/.gradle/8.5/checksums/checksums.lock b/client-mobile/.gradle/8.5/checksums/checksums.lock index fc9ae19..0f318f8 100644 Binary files a/client-mobile/.gradle/8.5/checksums/checksums.lock and b/client-mobile/.gradle/8.5/checksums/checksums.lock differ diff --git a/client-mobile/.gradle/8.5/checksums/md5-checksums.bin b/client-mobile/.gradle/8.5/checksums/md5-checksums.bin index 21de15b..6f892e4 100644 Binary files a/client-mobile/.gradle/8.5/checksums/md5-checksums.bin and b/client-mobile/.gradle/8.5/checksums/md5-checksums.bin differ diff --git a/client-mobile/.gradle/8.5/checksums/sha1-checksums.bin b/client-mobile/.gradle/8.5/checksums/sha1-checksums.bin index 202b7a7..e484b50 100644 Binary files a/client-mobile/.gradle/8.5/checksums/sha1-checksums.bin and b/client-mobile/.gradle/8.5/checksums/sha1-checksums.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index 7e44f2c..a031326 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index 6825755..4d294a6 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index 86b29e5..89c5a46 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index be8759d..f983062 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index 2e0db4b..7510778 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index f2b01f9..fda8133 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index e2d938a..6e04e93 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/app/build.gradle.kts b/client-mobile/app/build.gradle.kts index 7a9cea8..56ac671 100644 --- a/client-mobile/app/build.gradle.kts +++ b/client-mobile/app/build.gradle.kts @@ -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") diff --git a/client-mobile/app/src/main/AndroidManifest.xml b/client-mobile/app/src/main/AndroidManifest.xml index 853a047..8ecb4e2 100644 --- a/client-mobile/app/src/main/AndroidManifest.xml +++ b/client-mobile/app/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ diff --git a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainApplication.kt b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainApplication.kt index 04b1e02..1698408 100644 --- a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainApplication.kt +++ b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainApplication.kt @@ -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() + } +} diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index ea57dda..acd983f 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -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>() {}.type - val mediaDtos: List = gson.fromJson(mediaJson, mediaTypeToken) ?: emptyList() + val mediaDtos: List = try { + gson.fromJson(mediaJson, mediaTypeToken) + } catch (e: Exception) { + emptyList() + } ?: emptyList() val reactionsTypeToken = object : com.google.gson.reflect.TypeToken>() {}.type - val reactionDtos: List = gson.fromJson(reactionsJson, reactionsTypeToken) ?: emptyList() + val reactionDtos: List = try { + // Try to parse as array (List) + 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>() {}.type + val map: Map = 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 diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index 492d9c2..fde8cb6 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -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(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 } + ) + } } diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index dcabf3b..5a3b5b3 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -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 = emptyList(), val gifCategories: List = emptyList(), val isGifsLoading: Boolean = false, - val initialScrollIndex: Int? = null + val initialScrollIndex: Int? = null, + val pendingAttachments: List = 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") } diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt index 7ec9126..ec68151 100644 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ b/client-mobile/chats/presentation/components/MessageBubble.kt @@ -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, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) { - val items = media.take(4) +fun PhotoGrid(mediaList: List, 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 + ) + } + } + } +} diff --git a/client-mobile/core/presentation/components/AppAudioPlayer.kt b/client-mobile/core/presentation/components/AppAudioPlayer.kt index 3c653b6..2d89f46 100644 --- a/client-mobile/core/presentation/components/AppAudioPlayer.kt +++ b/client-mobile/core/presentation/components/AppAudioPlayer.kt @@ -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) + } + } ) } } diff --git a/client-mobile/core/presentation/components/AppMediaLightbox.kt b/client-mobile/core/presentation/components/AppMediaLightbox.kt index 248a13d..0a1e76f 100644 --- a/client-mobile/core/presentation/components/AppMediaLightbox.kt +++ b/client-mobile/core/presentation/components/AppMediaLightbox.kt @@ -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)) diff --git a/client-mobile/core/utils/DownloadUtils.kt b/client-mobile/core/utils/DownloadUtils.kt new file mode 100644 index 0000000..425e39a --- /dev/null +++ b/client-mobile/core/utils/DownloadUtils.kt @@ -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() + } + } + } +} diff --git a/client-mobile/core/utils/FileUtils.kt b/client-mobile/core/utils/FileUtils.kt index 12f5e9d..2e746e4 100644 --- a/client-mobile/core/utils/FileUtils.kt +++ b/client-mobile/core/utils/FileUtils.kt @@ -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 -> diff --git a/client-mobile/core/utils/ImageUtils.kt b/client-mobile/core/utils/ImageUtils.kt new file mode 100644 index 0000000..5e70d96 --- /dev/null +++ b/client-mobile/core/utils/ImageUtils.kt @@ -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 + } + } +}