feat: polish chat media sizing and album editing
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

- size single photo and video bubbles by real aspect ratio

- move private contact and blocked badges into the chat info sheet

- add drag reorder, inline remove, and crop/rotate tools to album preview

- tighten circle playback reset behavior at the end
This commit is contained in:
2026-04-05 23:32:05 +03:00
parent b7bce26697
commit a411d17cf0
3 changed files with 306 additions and 39 deletions

View File

@@ -4,6 +4,8 @@ package ru.daemonlord.messenger.ui.chat
import android.Manifest
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@@ -24,6 +26,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
@@ -101,6 +104,7 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
@@ -122,6 +126,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
@@ -1666,6 +1672,42 @@ private fun ChatScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isPrivateChat && state.isCounterpartRelationshipResolved) {
Row(
modifier = Modifier.padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (state.isCounterpartContact) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
) {
Text(
text = stringResource(id = R.string.chat_private_info_contact_badge),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (state.isCounterpartBlocked) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.error.copy(alpha = 0.16f),
) {
Text(
text = stringResource(id = R.string.chat_private_info_blocked_badge),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
Row(
@@ -3052,6 +3094,8 @@ private fun MediaBatchPreviewSheet(
val scope = rememberCoroutineScope()
val currentIndex = pagerState.currentPage.coerceIn(0, (mediaItems.size - 1).coerceAtLeast(0))
val currentItem = mediaItems.getOrNull(currentIndex)
var draggingIndex by remember { mutableStateOf<Int?>(null) }
var dragOffsetPx by remember { mutableStateOf(0f) }
LaunchedEffect(mediaItems.size) {
if (mediaItems.isEmpty()) {
@@ -3081,31 +3125,31 @@ private fun MediaBatchPreviewSheet(
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),
)
if (currentItem != null && currentItem.isEditableStaticImage) {
IconButton(
onClick = {
currentItem.rotateClockwise()?.let { updated ->
onUpdateItems(mediaItems.updatedAt(currentIndex, updated))
}
},
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(id = R.string.chat_media_preview_rotate),
)
}
IconButton(
onClick = {
currentItem.cropCenterSquare()?.let { updated ->
onUpdateItems(mediaItems.updatedAt(currentIndex, updated))
}
},
) {
Icon(
imageVector = Icons.Filled.Image,
contentDescription = stringResource(id = R.string.chat_media_preview_crop_square),
)
}
}
IconButton(
onClick = {
@@ -3136,6 +3180,20 @@ private fun MediaBatchPreviewSheet(
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color.Black.copy(alpha = 0.45f),
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp),
) {
Text(
text = stringResource(id = R.string.chat_media_preview_count_badge, page + 1, mediaItems.size),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
)
}
if (isVideo && previewUri != null) {
MediaBatchVideoPreview(previewUri = previewUri)
} else {
@@ -3149,10 +3207,17 @@ private fun MediaBatchPreviewSheet(
}
}
Text(
text = stringResource(id = R.string.chat_media_preview_reorder_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(mediaItems.size, key = { index -> mediaItems[index].fileName + index }) { index ->
val item = mediaItems[index]
val isSelected = index == currentIndex
val dragStepPx = with(LocalContext.current.resources.displayMetrics) { 88.dp.value * density }
Surface(
shape = RoundedCornerShape(14.dp),
color = if (isSelected) {
@@ -3168,12 +3233,57 @@ private fun MediaBatchPreviewSheet(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(14.dp))
.graphicsLayer {
translationX = if (draggingIndex == index) dragOffsetPx else 0f
}
.clickable {
scope.launch { pagerState.animateScrollToPage(index) }
},
) {
Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.pointerInput(mediaItems, index) {
detectDragGesturesAfterLongPress(
onDragStart = {
draggingIndex = index
dragOffsetPx = 0f
},
onDragCancel = {
draggingIndex = null
dragOffsetPx = 0f
},
onDragEnd = {
draggingIndex = null
dragOffsetPx = 0f
},
onDrag = { change, dragAmount ->
change.consume()
if (draggingIndex == null) draggingIndex = index
dragOffsetPx += dragAmount.x
val activeIndex = draggingIndex ?: index
when {
dragOffsetPx > dragStepPx / 2f && activeIndex < mediaItems.lastIndex -> {
onUpdateItems(mediaItems.move(activeIndex, activeIndex + 1))
draggingIndex = activeIndex + 1
dragOffsetPx -= dragStepPx
scope.launch {
pagerState.scrollToPage((currentIndex + 1).coerceAtMost(mediaItems.lastIndex))
}
}
dragOffsetPx < -dragStepPx / 2f && activeIndex > 0 -> {
onUpdateItems(mediaItems.move(activeIndex, activeIndex - 1))
draggingIndex = activeIndex - 1
dragOffsetPx += dragStepPx
scope.launch {
pagerState.scrollToPage((currentIndex - 1).coerceAtLeast(0))
}
}
}
},
)
},
contentAlignment = Alignment.Center,
) {
if (item.mimeType.startsWith("video/", ignoreCase = true)) {
@@ -3202,6 +3312,27 @@ private fun MediaBatchPreviewSheet(
contentScale = ContentScale.Crop,
)
}
Surface(
shape = CircleShape,
color = Color.Black.copy(alpha = 0.52f),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(20.dp)
.clip(CircleShape)
.clickable {
onUpdateItems(mediaItems.removeAt(index))
},
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(id = R.string.common_delete),
tint = Color.White,
modifier = Modifier.size(12.dp),
)
}
}
}
}
}
@@ -3285,11 +3416,53 @@ private fun List<PickedMediaPayload>.move(fromIndex: Int, toIndex: Int): List<Pi
return mutable.toList()
}
private fun List<PickedMediaPayload>.updatedAt(index: Int, item: PickedMediaPayload): List<PickedMediaPayload> {
if (index !in indices) return this
val mutable = toMutableList()
mutable[index] = item
return mutable.toList()
}
private fun List<PickedMediaPayload>.removeAt(index: Int): List<PickedMediaPayload> {
if (index !in indices) return this
return filterIndexed { currentIndex, _ -> currentIndex != index }
}
private val PickedMediaPayload.isEditableStaticImage: Boolean
get() = mimeType.startsWith("image/", ignoreCase = true) && !mimeType.contains("gif", ignoreCase = true)
private fun PickedMediaPayload.rotateClockwise(): PickedMediaPayload? {
if (!isEditableStaticImage) return this
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
val matrix = Matrix().apply { postRotate(90f) }
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
return rotated.toEditedImagePayload(fileName)
}
private fun PickedMediaPayload.cropCenterSquare(): PickedMediaPayload? {
if (!isEditableStaticImage) return this
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
val side = minOf(bitmap.width, bitmap.height)
val left = ((bitmap.width - side) / 2).coerceAtLeast(0)
val top = ((bitmap.height - side) / 2).coerceAtLeast(0)
val cropped = Bitmap.createBitmap(bitmap, left, top, side, side)
return cropped.toEditedImagePayload(fileName)
}
private fun Bitmap.toEditedImagePayload(originalFileName: String): PickedMediaPayload? {
val stream = java.io.ByteArrayOutputStream()
val ok = compress(Bitmap.CompressFormat.JPEG, 92, stream)
if (!ok) return null
val bytes = stream.toByteArray()
if (bytes.isEmpty()) return null
val baseName = originalFileName.substringBeforeLast('.').ifBlank { "photo_${System.currentTimeMillis()}" }
return PickedMediaPayload(
fileName = "${baseName}.jpg",
mimeType = "image/jpeg",
bytes = bytes,
)
}
@Composable
private fun CameraCaptureDialog(
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
@@ -3980,6 +4153,55 @@ private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? {
)
}
@Composable
private fun rememberRemoteMediaAspectRatio(
url: String,
isVideo: Boolean,
fallback: Float = 1f,
): Float {
val aspectRatio = produceState(initialValue = fallback, url, isVideo) {
value = withContext(Dispatchers.IO) {
runCatching {
if (isVideo) {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(url, emptyMap())
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toFloatOrNull()
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toFloatOrNull()
retriever.release()
if (width != null && height != null && height > 0f) width / height else fallback
} else {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
java.net.URL(url).openStream().use { input ->
BitmapFactory.decodeStream(input, null, options)
}
val width = options.outWidth.toFloat()
val height = options.outHeight.toFloat()
if (width > 0f && height > 0f) width / height else fallback
}
}.getOrDefault(fallback)
}.coerceIn(0.6f, 1.8f)
}
return aspectRatio.value
}
private fun mediaPreviewSizeModifier(
ratio: Float,
maxWidth: Dp,
maxHeight: Dp,
): Modifier {
val boundedRatio = ratio.coerceIn(0.6f, 1.8f)
val maxFrameRatio = maxWidth.value / maxHeight.value
return if (boundedRatio >= maxFrameRatio) {
Modifier
.width(maxWidth)
.aspectRatio(boundedRatio)
} else {
Modifier
.height(maxHeight)
.aspectRatio(boundedRatio)
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun MessageBubble(
@@ -4211,6 +4433,11 @@ private fun MessageBubble(
mediaBadgeLabel(fileType = "image/url", url = textUrl, context = context)
}
val openable = !isLegacyStickerMessage && badgeLabel == null
val legacyImageAspectRatio = if (isLegacyStickerMessage) {
1f
} else {
rememberRemoteMediaAspectRatio(url = textUrl, isVideo = false)
}
Box(
modifier = Modifier
.then(
@@ -4218,9 +4445,11 @@ private fun MessageBubble(
Modifier
.size(176.dp)
} else {
Modifier
.widthIn(max = singleMediaMaxWidth)
.heightIn(max = singleMediaMaxHeight)
mediaPreviewSizeModifier(
ratio = legacyImageAspectRatio,
maxWidth = singleMediaMaxWidth,
maxHeight = singleMediaMaxHeight,
)
},
)
.clip(RoundedCornerShape(12.dp))
@@ -4279,6 +4508,8 @@ private fun MessageBubble(
VideoAttachmentCard(
url = textUrl,
fileType = message.type,
maxWidth = singleMediaMaxWidth,
maxHeight = singleMediaMaxHeight,
onOpenViewer = { onAttachmentVideoClick(textUrl) },
)
}
@@ -4316,6 +4547,11 @@ private fun MessageBubble(
mediaBadgeLabel(fileType = single.fileType, url = single.fileUrl, context = context)
}
val openable = !isStickerImage && badgeLabel == null
val singleImageAspectRatio = if (isStickerImage) {
1f
} else {
rememberRemoteMediaAspectRatio(url = single.fileUrl, isVideo = false)
}
Box(
modifier = Modifier
.then(
@@ -4323,9 +4559,11 @@ private fun MessageBubble(
Modifier
.size(176.dp)
} else {
Modifier
.widthIn(max = singleMediaMaxWidth)
.heightIn(max = singleMediaMaxHeight)
mediaPreviewSizeModifier(
ratio = singleImageAspectRatio,
maxWidth = singleMediaMaxWidth,
maxHeight = singleMediaMaxHeight,
)
},
)
.clip(RoundedCornerShape(12.dp))
@@ -4432,6 +4670,8 @@ private fun MessageBubble(
VideoAttachmentCard(
url = attachment.fileUrl,
fileType = attachment.fileType,
maxWidth = singleMediaMaxWidth,
maxHeight = singleMediaMaxHeight,
onOpenViewer = { onAttachmentVideoClick(attachment.fileUrl) },
)
}
@@ -4518,9 +4758,12 @@ private fun MessageBubble(
private fun VideoAttachmentCard(
url: String,
fileType: String,
maxWidth: Dp,
maxHeight: Dp,
onOpenViewer: () -> Unit,
) {
val context = LocalContext.current
val aspectRatio = rememberRemoteMediaAspectRatio(url = url, isVideo = true, fallback = 1f)
val previewRequest = remember(url) {
ImageRequest.Builder(context)
.data(url)
@@ -4530,15 +4773,20 @@ private fun VideoAttachmentCard(
}
Column(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = maxWidth)
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp))
.clickable(onClick = onOpenViewer)
.padding(8.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(186.dp)
.then(
mediaPreviewSizeModifier(
ratio = aspectRatio,
maxWidth = maxWidth - 16.dp,
maxHeight = maxHeight,
),
)
.clip(RoundedCornerShape(10.dp)),
) {
AsyncImage(
@@ -4627,10 +4875,17 @@ private fun CircleVideoAttachmentPlayer(
LaunchedEffect(playerState.isPlaying) {
emitTopStrip(playerState.isPlaying)
}
val progress = if (playerState.durationMs > 0L) {
(playerState.positionMs.toFloat() / playerState.durationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
LaunchedEffect(playerState.positionMs, playerState.isPlaying) {
if (!playerState.isPlaying && playerState.positionMs == 0L) {
emitTopStrip(false)
}
}
val progress = remember(playerState.positionMs, playerState.durationMs, playerState.isPlaying) {
when {
playerState.durationMs <= 0L -> 0f
!playerState.isPlaying && playerState.positionMs <= 0L -> 0f
else -> (playerState.positionMs.toFloat() / playerState.durationMs.toFloat()).coerceIn(0f, 1f)
}
}
val progressTrackColor = Color.White.copy(alpha = 0.22f)
val progressActiveColor = MaterialTheme.colorScheme.primary

View File

@@ -364,4 +364,10 @@
<string name="chat_media_preview_caption_hint">Добавить подпись</string>
<string name="chat_media_preview_move_left">Переместить влево</string>
<string name="chat_media_preview_move_right">Переместить вправо</string>
<string name="chat_media_preview_crop_square">Обрезать в квадрат</string>
<string name="chat_media_preview_rotate">Повернуть</string>
<string name="chat_media_preview_count_badge">%1$d из %2$d</string>
<string name="chat_media_preview_reorder_hint">Удерживай и перетаскивай миниатюры для сортировки. Нажми X, чтобы удалить.</string>
<string name="chat_private_info_contact_badge">Контакт</string>
<string name="chat_private_info_blocked_badge">Заблокирован</string>
</resources>

View File

@@ -364,4 +364,10 @@
<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>
<string name="chat_media_preview_crop_square">Crop square</string>
<string name="chat_media_preview_rotate">Rotate</string>
<string name="chat_media_preview_count_badge">%1$d of %2$d</string>
<string name="chat_media_preview_reorder_hint">Hold and drag thumbnails to reorder. Tap X to remove.</string>
<string name="chat_private_info_contact_badge">Contact</string>
<string name="chat_private_info_blocked_badge">Blocked</string>
</resources>