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
This commit is contained in:
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user