diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 9fb9cb7..1a72595 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -16,6 +16,7 @@ import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.enableEdgeToEdge +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -320,8 +321,16 @@ fun ChatRoute( AppAudioFocusCoordinator.release("voice-recording") } } - val pickMediaLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), + val pickVisualMediaLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 10), + ) { uris -> + val picked = uris.mapNotNull { it.readMediaPayload(context) } + if (picked.isNotEmpty()) { + viewModel.onMediaBatchPicked(picked) + } + } + val pickFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), ) { uri -> val picked = uri?.readMediaPayload(context) ?: return@rememberLauncherForActivityResult viewModel.onMediaPicked( @@ -351,7 +360,14 @@ fun ChatRoute( onToggleReaction = viewModel::onToggleReaction, onCancelComposeAction = viewModel::onCancelComposeAction, onLoadMore = viewModel::loadMore, - onPickMedia = { pickMediaLauncher.launch("*/*") }, + onPickVisualMedia = { + pickVisualMediaLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo), + ) + }, + onPickFile = { + pickFileLauncher.launch(arrayOf("*/*")) + }, onCapturePhoto = { if (hasCameraPermission) { cameraCaptureMode = CameraCaptureMode.Photo @@ -517,7 +533,8 @@ private data class ChatScreenActions( val onToggleReaction: (String) -> Unit, val onCancelComposeAction: () -> Unit, val onLoadMore: () -> Unit, - val onPickMedia: () -> Unit, + val onPickVisualMedia: () -> Unit, + val onPickFile: () -> Unit, val onCapturePhoto: () -> Unit, val onCaptureVideo: () -> Unit, val onCaptureCircleVideo: () -> Unit, @@ -571,7 +588,8 @@ private fun ChatScreen( val onToggleReaction = actions.onToggleReaction val onCancelComposeAction = actions.onCancelComposeAction val onLoadMore = actions.onLoadMore - val onPickMedia = actions.onPickMedia + val onPickVisualMedia = actions.onPickVisualMedia + val onPickFile = actions.onPickFile val onCapturePhoto = actions.onCapturePhoto val onCaptureVideo = actions.onCaptureVideo val onCaptureCircleVideo = actions.onCaptureCircleVideo @@ -2082,7 +2100,28 @@ private fun ChatScreen( .clip(RoundedCornerShape(12.dp)) .clickable { showAttachmentSheet = false - onPickMedia() + onPickVisualMedia() + }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(imageVector = Icons.Filled.Image, contentDescription = null) + Text(stringResource(id = R.string.chat_attachment_photos_videos)) + } + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + showAttachmentSheet = false + onPickFile() }, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), ) { @@ -2094,7 +2133,7 @@ private fun ChatScreen( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Icon(imageVector = Icons.Filled.AttachFile, contentDescription = null) - Text(stringResource(id = R.string.chat_attachment_gallery_file)) + Text(stringResource(id = R.string.chat_attachment_files)) } } Surface( @@ -2963,7 +3002,7 @@ private fun EmojiPickerSheet( } } -private data class PickedMediaPayload( +data class PickedMediaPayload( val fileName: String, val mimeType: String, val bytes: ByteArray, @@ -3926,11 +3965,16 @@ private fun MessageBubble( } }, ) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), + ) AsyncImage( model = textUrl, contentDescription = "Image", modifier = Modifier.fillMaxSize(), - contentScale = if (isLegacyStickerMessage) ContentScale.Fit else ContentScale.Crop, + contentScale = ContentScale.Fit, ) if (!badgeLabel.isNullOrBlank()) { MediaTypeBadge( @@ -4022,11 +4066,16 @@ private fun MessageBubble( if (openable) base.clickable { onAttachmentImageClick(single.fileUrl) } else base }, ) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), + ) AsyncImage( model = single.fileUrl, contentDescription = "Image", modifier = Modifier.fillMaxSize(), - contentScale = if (isStickerImage) ContentScale.Fit else ContentScale.Crop, + contentScale = ContentScale.Fit, ) if (!badgeLabel.isNullOrBlank()) { MediaTypeBadge( @@ -4059,11 +4108,16 @@ private fun MessageBubble( if (openable) base.clickable { onAttachmentImageClick(image.fileUrl) } else base }, ) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), + ) AsyncImage( model = image.fileUrl, contentDescription = "Image", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, + contentScale = ContentScale.Fit, ) if (!badgeLabel.isNullOrBlank()) { MediaTypeBadge( diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index a249e5e..b6173e1 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -662,6 +662,46 @@ class ChatViewModel @Inject constructor( } } + fun onMediaBatchPicked(items: List) { + if (items.isEmpty()) return + viewModelScope.launch { + _uiState.update { it.copy(isUploadingMedia = true, errorMessage = null) } + val caption = uiState.value.inputText.trim().ifBlank { null } + val replyToMessageId = uiState.value.replyToMessage?.id + items.forEachIndexed { index, item -> + when ( + val result = sendMediaMessageUseCase( + chatId = chatId, + fileName = item.fileName, + mimeType = item.mimeType, + bytes = item.bytes, + caption = if (index == 0) caption else null, + replyToMessageId = if (index == 0) replyToMessageId else null, + ) + ) { + is AppResult.Success -> Unit + is AppResult.Error -> { + _uiState.update { + it.copy( + isUploadingMedia = false, + errorMessage = result.reason.toUiMessage(), + ) + } + return@launch + } + } + } + _uiState.update { + it.copy( + isUploadingMedia = false, + inputText = "", + replyToMessage = null, + editingMessage = null, + ) + } + } + } + fun onSendPresetMediaUrl(url: String) { val normalizedUrl = url.trim() if (normalizedUrl.isBlank()) return diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index a607e2b..7f7a5ee 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -164,6 +164,8 @@ Видео Вложение Галерея / Файл + Фото и видео + Файлы Сделать фото Снять видео GIF diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 620b3ac..eceb27d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -164,6 +164,8 @@ Video Attachment Gallery / File + Photos & videos + Files Take photo Record video GIF