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.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
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.layout.aspectRatio
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.TextRange
|
import androidx.compose.ui.text.TextRange
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Forward
|
import androidx.compose.material.icons.automirrored.filled.Forward
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.ArrowDownward
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
@@ -256,6 +260,8 @@ fun ChatRoute(
|
|||||||
var isCircleRecordingLocked by remember { mutableStateOf(false) }
|
var isCircleRecordingLocked by remember { mutableStateOf(false) }
|
||||||
var pendingCircleFinalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(null) }
|
var pendingCircleFinalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(null) }
|
||||||
var cameraCaptureMode by remember { mutableStateOf<CameraCaptureMode?>(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 {
|
var hasAudioPermission by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
@@ -326,7 +332,8 @@ fun ChatRoute(
|
|||||||
) { uris ->
|
) { uris ->
|
||||||
val picked = uris.mapNotNull { it.readMediaPayload(context) }
|
val picked = uris.mapNotNull { it.readMediaPayload(context) }
|
||||||
if (picked.isNotEmpty()) {
|
if (picked.isNotEmpty()) {
|
||||||
viewModel.onMediaBatchPicked(picked)
|
pendingMediaBatch = picked
|
||||||
|
mediaBatchCaption = state.inputText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val pickFileLauncher = rememberLauncherForActivityResult(
|
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(
|
private data class ChatScreenActions(
|
||||||
@@ -3006,6 +3033,7 @@ data class PickedMediaPayload(
|
|||||||
val fileName: String,
|
val fileName: String,
|
||||||
val mimeType: String,
|
val mimeType: String,
|
||||||
val bytes: ByteArray,
|
val bytes: ByteArray,
|
||||||
|
val previewUri: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private enum class CameraCaptureMode {
|
private enum class CameraCaptureMode {
|
||||||
@@ -3028,6 +3056,258 @@ private data class VideoFrameInfo(
|
|||||||
val height: Int,
|
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
|
@Composable
|
||||||
private fun CameraCaptureDialog(
|
private fun CameraCaptureDialog(
|
||||||
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
|
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
|
||||||
@@ -3714,6 +3994,7 @@ private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? {
|
|||||||
fileName = name,
|
fileName = name,
|
||||||
mimeType = mime,
|
mimeType = mime,
|
||||||
bytes = bytes,
|
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
|
if (items.isEmpty()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isUploadingMedia = true, errorMessage = null) }
|
_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
|
val replyToMessageId = uiState.value.replyToMessage?.id
|
||||||
when (
|
when (
|
||||||
val result = sendMediaGroupMessageUseCase(
|
val result = sendMediaGroupMessageUseCase(
|
||||||
|
|||||||
@@ -351,4 +351,8 @@
|
|||||||
<string name="account_error_invalid_credentials">Неверные учетные данные.</string>
|
<string name="account_error_invalid_credentials">Неверные учетные данные.</string>
|
||||||
<string name="account_error_unauthorized">Не авторизовано.</string>
|
<string name="account_error_unauthorized">Не авторизовано.</string>
|
||||||
<string name="chat_audio_strip_video_note">Видеокружок</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>
|
</resources>
|
||||||
|
|||||||
@@ -351,4 +351,8 @@
|
|||||||
<string name="account_error_invalid_credentials">Invalid credentials.</string>
|
<string name="account_error_invalid_credentials">Invalid credentials.</string>
|
||||||
<string name="account_error_unauthorized">Unauthorized.</string>
|
<string name="account_error_unauthorized">Unauthorized.</string>
|
||||||
<string name="chat_audio_strip_video_note">Video note</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>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user