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 8b3b6e7..d7f4edd 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 @@ -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(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.move(fromIndex: Int, toIndex: Int): List.updatedAt(index: Int, item: PickedMediaPayload): List { + if (index !in indices) return this + val mutable = toMutableList() + mutable[index] = item + return mutable.toList() +} + private fun List.removeAt(index: Int): List { 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 diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 76c3636..f62a71b 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -364,4 +364,10 @@ Добавить подпись Переместить влево Переместить вправо + Обрезать в квадрат + Повернуть + %1$d из %2$d + Удерживай и перетаскивай миниатюры для сортировки. Нажми X, чтобы удалить. + Контакт + Заблокирован diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 33c0c7d..dcbdc7a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -364,4 +364,10 @@ Add a caption Move left Move right + Crop square + Rotate + %1$d of %2$d + Hold and drag thumbnails to reorder. Tap X to remove. + Contact + Blocked