From 64dfd18ee1107f936ceb8a29f83808261d4b230d Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 5 Apr 2026 20:37:10 +0300 Subject: [PATCH] feat: add album preview sheet before sending - open a media preview sheet for picked photos and videos before upload - support reordering, removing items, and editing the shared caption in the sheet - send the confirmed batch through the existing album flow as one message with attachments --- .../messenger/ui/chat/ChatScreen.kt | 283 +++++++++++++++++- .../messenger/ui/chat/ChatViewModel.kt | 4 +- .../app/src/main/res/values-ru/strings.xml | 4 + android/app/src/main/res/values/strings.xml | 4 + 4 files changed, 292 insertions(+), 3 deletions(-) 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 1a72595..58884fe 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 @@ -19,6 +19,7 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.ExperimentalFoundationApi @@ -48,6 +49,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager @@ -113,6 +115,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer @@ -144,6 +147,7 @@ import androidx.core.content.FileProvider import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Forward import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward @@ -256,6 +260,8 @@ fun ChatRoute( var isCircleRecordingLocked by remember { mutableStateOf(false) } var pendingCircleFinalizeAction by remember { mutableStateOf(null) } var cameraCaptureMode by remember { mutableStateOf(null) } + var pendingMediaBatch by remember { mutableStateOf>(emptyList()) } + var mediaBatchCaption by remember { mutableStateOf("") } var hasAudioPermission by remember { mutableStateOf( ContextCompat.checkSelfPermission( @@ -326,7 +332,8 @@ fun ChatRoute( ) { uris -> val picked = uris.mapNotNull { it.readMediaPayload(context) } if (picked.isNotEmpty()) { - viewModel.onMediaBatchPicked(picked) + pendingMediaBatch = picked + mediaBatchCaption = state.inputText } } val pickFileLauncher = rememberLauncherForActivityResult( @@ -513,6 +520,26 @@ fun ChatRoute( }, ) } + if (pendingMediaBatch.isNotEmpty()) { + MediaBatchPreviewSheet( + mediaItems = pendingMediaBatch, + caption = mediaBatchCaption, + onCaptionChanged = { mediaBatchCaption = it }, + onDismiss = { + pendingMediaBatch = emptyList() + mediaBatchCaption = "" + }, + onUpdateItems = { pendingMediaBatch = it }, + onSend = { + viewModel.onMediaBatchPicked( + items = pendingMediaBatch, + captionOverride = mediaBatchCaption.trim().ifBlank { null }, + ) + pendingMediaBatch = emptyList() + mediaBatchCaption = "" + }, + ) + } } private data class ChatScreenActions( @@ -3006,6 +3033,7 @@ data class PickedMediaPayload( val fileName: String, val mimeType: String, val bytes: ByteArray, + val previewUri: String? = null, ) private enum class CameraCaptureMode { @@ -3028,6 +3056,258 @@ private data class VideoFrameInfo( val height: Int, ) +@Composable +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +private fun MediaBatchPreviewSheet( + mediaItems: List, + caption: String, + onCaptionChanged: (String) -> Unit, + onDismiss: () -> Unit, + onUpdateItems: (List) -> Unit, + onSend: () -> Unit, +) { + val pagerState = rememberPagerState(initialPage = 0) { mediaItems.size } + val scope = rememberCoroutineScope() + val currentIndex = pagerState.currentPage.coerceIn(0, (mediaItems.size - 1).coerceAtLeast(0)) + val currentItem = mediaItems.getOrNull(currentIndex) + + LaunchedEffect(mediaItems.size) { + if (mediaItems.isEmpty()) { + onDismiss() + } else if (pagerState.currentPage > mediaItems.lastIndex) { + pagerState.scrollToPage(mediaItems.lastIndex) + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.chat_media_preview_title, mediaItems.size), + style = MaterialTheme.typography.titleMedium, + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + onClick = { + if (currentIndex > 0) { + onUpdateItems(mediaItems.move(currentIndex, currentIndex - 1)) + } + }, + enabled = currentIndex > 0, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.chat_media_preview_move_left), + ) + } + IconButton( + onClick = { + if (currentIndex < mediaItems.lastIndex) { + onUpdateItems(mediaItems.move(currentIndex, currentIndex + 1)) + } + }, + enabled = currentIndex < mediaItems.lastIndex, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = stringResource(id = R.string.chat_media_preview_move_right), + ) + } + IconButton( + onClick = { + onUpdateItems(mediaItems.removeAt(currentIndex)) + }, + ) { + Icon( + imageVector = Icons.Filled.DeleteOutline, + contentDescription = stringResource(id = R.string.common_delete), + ) + } + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .height(320.dp), + ) { page -> + val item = mediaItems[page] + val previewUri = item.previewUri + val isVideo = item.mimeType.startsWith("video/", ignoreCase = true) + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center, + ) { + if (isVideo && previewUri != null) { + MediaBatchVideoPreview(previewUri = previewUri) + } else { + AsyncImage( + model = previewUri ?: item.bytes, + contentDescription = item.fileName, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + } + } + + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(mediaItems.size, key = { index -> mediaItems[index].fileName + index }) { index -> + val item = mediaItems[index] + val isSelected = index == currentIndex + Surface( + shape = RoundedCornerShape(14.dp), + color = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + }, + border = if (isSelected) { + BorderStroke(1.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(14.dp)) + .clickable { + scope.launch { pagerState.animateScrollToPage(index) } + }, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (item.mimeType.startsWith("video/", ignoreCase = true)) { + AsyncImage( + model = item.previewUri, + contentDescription = item.fileName, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + Surface( + shape = CircleShape, + color = Color.Black.copy(alpha = 0.45f), + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(6.dp), + ) + } + } else { + AsyncImage( + model = item.previewUri ?: item.bytes, + contentDescription = item.fileName, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } + } + } + } + + OutlinedTextField( + value = caption, + onValueChange = onCaptionChanged, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4, + placeholder = { + Text(text = stringResource(id = R.string.chat_media_preview_caption_hint)) + }, + ) + + currentItem?.let { item -> + Text( + text = item.fileName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) { + Text(text = stringResource(id = R.string.common_cancel)) + } + Button( + onClick = onSend, + modifier = Modifier.weight(1f), + enabled = mediaItems.isNotEmpty(), + ) { + Text(text = stringResource(id = R.string.common_send)) + } + } + } + } +} + +@Composable +private fun MediaBatchVideoPreview(previewUri: String) { + val context = LocalContext.current + val exoPlayer = remember(previewUri) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(previewUri)) + repeatMode = Player.REPEAT_MODE_ONE + volume = 0f + prepare() + playWhenReady = true + } + } + DisposableEffect(exoPlayer) { + onDispose { exoPlayer.release() } + } + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + } + }, + modifier = Modifier.fillMaxSize(), + ) +} + +private fun List.move(fromIndex: Int, toIndex: Int): List { + if (fromIndex == toIndex || fromIndex !in indices || toIndex !in indices) return this + val mutable = toMutableList() + val item = mutable.removeAt(fromIndex) + mutable.add(toIndex, item) + return mutable.toList() +} + +private fun List.removeAt(index: Int): List { + if (index !in indices) return this + return filterIndexed { currentIndex, _ -> currentIndex != index } +} + @Composable private fun CameraCaptureDialog( lifecycleOwner: androidx.lifecycle.LifecycleOwner, @@ -3714,6 +3994,7 @@ private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? { fileName = name, mimeType = mime, bytes = bytes, + previewUri = toString(), ) } 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 ff93834..c645e1f 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 @@ -665,11 +665,11 @@ class ChatViewModel @Inject constructor( } } - fun onMediaBatchPicked(items: List) { + fun onMediaBatchPicked(items: List, captionOverride: String? = null) { if (items.isEmpty()) return viewModelScope.launch { _uiState.update { it.copy(isUploadingMedia = true, errorMessage = null) } - val caption = uiState.value.inputText.trim().ifBlank { null } + val caption = captionOverride ?: uiState.value.inputText.trim().ifBlank { null } val replyToMessageId = uiState.value.replyToMessage?.id when ( val result = sendMediaGroupMessageUseCase( diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 7f7a5ee..f48c7a0 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -351,4 +351,8 @@ Неверные учетные данные. Не авторизовано. Видеокружок + Выбрано: %1$d + Добавить подпись + Переместить влево + Переместить вправо diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index eceb27d..9d1e2ab 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -351,4 +351,8 @@ Invalid credentials. Unauthorized. Video note + %1$d selected + Add a caption + Move left + Move right