feat: add album preview sheet before sending
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

- 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
This commit is contained in:
2026-04-05 20:37:10 +03:00
parent 7d7996fb20
commit 64dfd18ee1
4 changed files with 292 additions and 3 deletions

View File

@@ -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<CircleFinalizeAction?>(null) }
var cameraCaptureMode by remember { mutableStateOf<CameraCaptureMode?>(null) }
var pendingMediaBatch by remember { mutableStateOf<List<PickedMediaPayload>>(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<PickedMediaPayload>,
caption: String,
onCaptionChanged: (String) -> Unit,
onDismiss: () -> Unit,
onUpdateItems: (List<PickedMediaPayload>) -> 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<PickedMediaPayload>.move(fromIndex: Int, toIndex: Int): List<PickedMediaPayload> {
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<PickedMediaPayload>.removeAt(index: Int): List<PickedMediaPayload> {
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(),
)
}

View File

@@ -665,11 +665,11 @@ class ChatViewModel @Inject constructor(
}
}
fun onMediaBatchPicked(items: List<PickedMediaPayload>) {
fun onMediaBatchPicked(items: List<PickedMediaPayload>, 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(

View File

@@ -351,4 +351,8 @@
<string name="account_error_invalid_credentials">Неверные учетные данные.</string>
<string name="account_error_unauthorized">Не авторизовано.</string>
<string name="chat_audio_strip_video_note">Видеокружок</string>
<string name="chat_media_preview_title">Выбрано: %1$d</string>
<string name="chat_media_preview_caption_hint">Добавить подпись</string>
<string name="chat_media_preview_move_left">Переместить влево</string>
<string name="chat_media_preview_move_right">Переместить вправо</string>
</resources>

View File

@@ -351,4 +351,8 @@
<string name="account_error_invalid_credentials">Invalid credentials.</string>
<string name="account_error_unauthorized">Unauthorized.</string>
<string name="chat_audio_strip_video_note">Video note</string>
<string name="chat_media_preview_title">%1$d selected</string>
<string name="chat_media_preview_caption_hint">Add a caption</string>
<string name="chat_media_preview_move_left">Move left</string>
<string name="chat_media_preview_move_right">Move right</string>
</resources>