From bbaa04cda58062c0948e1a674b0be084a7be0ef5 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 10 Feb 2026 00:06:41 +0500 Subject: [PATCH] feat: implement avatar animation and enhance image sharing functionality --- .../com/rosetta/messenger/MainActivity.kt | 11 +- .../messenger/ui/chats/ChatDetailScreen.kt | 16 +- .../messenger/ui/chats/ChatViewModel.kt | 49 ++++++ .../messenger/ui/chats/ChatsListScreen.kt | 65 ++++++-- .../messenger/ui/chats/SearchScreen.kt | 5 + .../chats/components/AttachmentComponents.kt | 80 +++++++-- .../chats/components/ChatDetailComponents.kt | 95 ++++++----- .../ui/chats/components/ImageEditorScreen.kt | 155 +++++++++++++++--- .../metaball/ProfileMetaballOverlay.kt | 82 ++++++--- .../ui/settings/OtherProfileScreen.kt | 133 +++++++++------ 10 files changed, 523 insertions(+), 168 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 1bd02c0..47994ed 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -918,16 +918,25 @@ fun MainScreen( ) } + var isOtherProfileSwipeEnabled by remember { mutableStateOf(true) } + LaunchedEffect(selectedOtherUser?.publicKey) { + isOtherProfileSwipeEnabled = true + } + SwipeBackContainer( isVisible = selectedOtherUser != null, onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } }, - isDarkTheme = isDarkTheme + isDarkTheme = isDarkTheme, + swipeEnabled = isOtherProfileSwipeEnabled ) { selectedOtherUser?.let { currentOtherUser -> OtherProfileScreen( user = currentOtherUser, isDarkTheme = isDarkTheme, onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } }, + onSwipeBackEnabledChanged = { enabled -> + isOtherProfileSwipeEnabled = enabled + }, avatarRepository = avatarRepository, currentUserPublicKey = accountPublicKey, currentUserPrivateKey = accountPrivateKey diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index c1debbb..1831223 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -2283,13 +2283,15 @@ fun ChatDetailScreen( inputFocusTrigger++ }, onSendAll = { imagesWithCaptions -> - // 🚀 Мгновенный optimistic UI для каждого фото - for (imageWithCaption in imagesWithCaptions) { - viewModel.sendImageFromUri( - imageWithCaption.uri, - imageWithCaption.caption - ) - } + val imageUris = imagesWithCaptions.map { it.uri } + val groupCaption = + imagesWithCaptions + .firstOrNull { it.caption.isNotBlank() } + ?.caption + ?.trim() + .orEmpty() + + viewModel.sendImageGroupFromUris(imageUris, groupCaption) showMediaPicker = false }, isDarkTheme = isDarkTheme, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 08e3abf..1b57e2c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -2177,6 +2177,55 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val height: Int = 0 ) + /** + * 🖼️ Отправка группы изображений из URI как одного media-group сообщения. + * Нужна для корректного коллажа у получателя (а не отдельных фото-сообщений). + */ + fun sendImageGroupFromUris(imageUris: List, caption: String = "") { + if (imageUris.isEmpty()) return + + if (imageUris.size == 1) { + sendImageFromUri(imageUris.first(), caption) + return + } + + val context = getApplication() + + backgroundUploadScope.launch { + val preparedImages = + imageUris.mapNotNull { uri -> + val (width, height) = + com.rosetta.messenger.utils.MediaUtils.getImageDimensions( + context, + uri + ) + val imageBase64 = + com.rosetta.messenger.utils.MediaUtils.uriToBase64Image( + context, + uri + ) + ?: return@mapNotNull null + val blurhash = + com.rosetta.messenger.utils.MediaUtils.generateBlurhash( + context, + uri + ) + ImageData( + base64 = imageBase64, + blurhash = blurhash, + width = width, + height = height + ) + } + + if (preparedImages.isEmpty()) return@launch + + withContext(Dispatchers.Main) { + sendImageGroup(preparedImages, caption) + } + } + } + fun sendImageGroup(images: List, caption: String = "") { if (images.isEmpty()) return diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 3197fab..ca82c03 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -145,7 +145,7 @@ fun getAvatarText(publicKey: String): String { return publicKey.take(2).uppercase() } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ChatsListScreen( isDarkTheme: Boolean, @@ -1204,7 +1204,20 @@ fun ChatsListScreen( } } - Column { + Column( + modifier = + Modifier.animateItemPlacement( + animationSpec = + spring( + dampingRatio = + Spring + .DampingRatioNoBouncy, + stiffness = + Spring + .StiffnessLow + ) + ) + ) { SwipeableDialogItem( dialog = dialog, @@ -1802,12 +1815,18 @@ fun SwipeableDialogItem( isPinned: Boolean = false, onPin: () -> Unit = {} ) { - val backgroundColor = + val targetBackgroundColor = if (isPinned) { if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED) } else { if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) } + val backgroundColor by + animateColorAsState( + targetValue = targetBackgroundColor, + animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing), + label = "pinnedBackground" + ) var offsetX by remember { mutableStateOf(0f) } // 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete) val buttonCount = if (isSavedMessages) 2 else 3 @@ -2551,15 +2570,37 @@ fun DialogItemContent( } } - // 📌 Pin icon - if (isPinned) { - Spacer(modifier = Modifier.width(6.dp)) - Icon( - imageVector = TablerIcons.Pin, - contentDescription = "Pinned", - tint = secondaryTextColor.copy(alpha = 0.5f), - modifier = Modifier.size(16.dp) - ) + // 📌 Pin icon with smooth in/out animation + AnimatedVisibility( + visible = isPinned, + enter = + fadeIn(animationSpec = tween(170)) + + scaleIn( + initialScale = 0.72f, + animationSpec = + spring( + dampingRatio = + Spring.DampingRatioLowBouncy, + stiffness = + Spring.StiffnessMedium + ) + ), + exit = + fadeOut(animationSpec = tween(130)) + + scaleOut( + targetScale = 0.72f, + animationSpec = tween(130) + ) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = TablerIcons.Pin, + contentDescription = "Pinned", + tint = secondaryTextColor.copy(alpha = 0.5f), + modifier = Modifier.size(16.dp) + ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 8034081..efb93b1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -84,6 +84,9 @@ fun SearchScreen( val searchResults by searchViewModel.searchResults.collectAsState() val isSearching by searchViewModel.isSearching.collectAsState() + // Always reset query/results when leaving Search screen (back/swipe/navigation). + DisposableEffect(Unit) { onDispose { searchViewModel.clearSearchQuery() } } + // Recent users - отложенная подписка val recentUsers by RecentSearchesManager.recentUsers.collectAsState() @@ -111,6 +114,7 @@ fun SearchScreen( // 🔥 Обработка системного жеста Back BackHandler { hideKeyboardInstantly() + searchViewModel.clearSearchQuery() onBackClick() } @@ -155,6 +159,7 @@ fun SearchScreen( IconButton( onClick = { hideKeyboardInstantly() + searchViewModel.clearSearchQuery() onBackClick() } ) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 6d1b99d..d07fbae 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -53,6 +53,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.compose.ui.platform.LocalConfiguration +import java.io.ByteArrayInputStream import kotlin.math.min private const val TAG = "AttachmentComponents" @@ -148,6 +149,8 @@ object TelegramBubbleSpec { // maxWidth = min(AndroidUtilities.displaySize.x, AndroidUtilities.displaySize.y) * 0.5f // Telegram использует ~50-65% ширины экрана fun maxPhotoWidth(screenWidth: Int): Int = (screenWidth * 0.65f).toInt() + // Multi-photo collage should be slightly larger than a single media bubble. + fun maxCollageWidth(screenWidth: Int): Int = (screenWidth * 0.72f).toInt() val maxPhotoHeight = 360 // dp, примерно maxWidth + 100 val minPhotoWidth = 150 // dp val minPhotoHeight = 120 // dp @@ -166,7 +169,7 @@ object TelegramBubbleSpec { val timeTextSize = 11.sp // === COLLAGE === - val collageSpacing = 2.dp // Отступ между фото в коллаже + val collageSpacing = 1.dp // Тонкий разделитель как в Telegram // === TEXT === val messageTextSize = 16.sp @@ -292,6 +295,12 @@ fun ImageCollage( ) { val count = attachments.size val spacing = TelegramBubbleSpec.collageSpacing + val collageDividerColor = + if (isOutgoing) { + Color.White.copy(alpha = if (isDarkTheme) 0.18f else 0.22f) + } else { + if (isDarkTheme) Color(0xFF2D3036) else Color(0xFFD8DEE8) + } // Показываем время и статус только если нет caption val showOverlayOnLast = !hasCaption @@ -311,7 +320,7 @@ fun ImageCollage( RoundedCornerShape(TelegramBubbleSpec.photoRadius) } - Box(modifier = modifier.clip(collageShape)) { + Box(modifier = modifier.clip(collageShape).background(collageDividerColor)) { when (count) { 1 -> { // Одно фото - размер определяется самим фото (Telegram style) @@ -861,8 +870,10 @@ fun ImageAttachment( val configuration = LocalConfiguration.current val screenWidthDp = configuration.screenWidthDp - val actualWidth = if (attachment.width > 0) attachment.width else imageBitmap?.width ?: 0 - val actualHeight = if (attachment.height > 0) attachment.height else imageBitmap?.height ?: 0 + // For incoming media where server dimensions are often missing, keep stable bubble-based fallback size + // instead of reflowing from decoded bitmap dimensions after download. + val actualWidth = attachment.width.takeIf { it > 0 } ?: 0 + val actualHeight = attachment.height.takeIf { it > 0 } ?: 0 val (imageWidth, imageHeight) = remember(actualWidth, actualHeight, fillMaxSize, aspectRatio, screenWidthDp) { @@ -871,10 +882,6 @@ fun ImageAttachment( } else { // Telegram: maxWidth = screenWidth * 0.65 val maxPhotoWidthPx = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp) - val maxPhotoWidth = maxPhotoWidthPx.dp - val maxPhotoHeight = TelegramBubbleSpec.maxPhotoHeight.dp - val minHeight = TelegramBubbleSpec.minPhotoHeight.dp - val minWidth = TelegramBubbleSpec.minPhotoWidth.dp if (actualWidth > 0 && actualHeight > 0) { val ar = actualWidth.toFloat() / actualHeight.toFloat() @@ -911,7 +918,14 @@ fun ImageAttachment( w.dp to h.dp } else { - 200.dp to 200.dp + // Match placeholder size to bubble width when media dimensions are still unknown. + val fallbackWidthPx = maxPhotoWidthPx.toFloat() + val fallbackHeightPx = + (fallbackWidthPx * 0.75f).coerceIn( + TelegramBubbleSpec.minPhotoHeight.toFloat(), + TelegramBubbleSpec.maxPhotoHeight.toFloat() + ) + fallbackWidthPx.dp to fallbackHeightPx.dp } } } @@ -1926,7 +1940,53 @@ private fun base64ToBitmap(base64: String): Bitmap? { base64 } val bytes = Base64.decode(cleanBase64, Base64.DEFAULT) - BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + var bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + + val orientation = + ByteArrayInputStream(bytes).use { stream -> + ExifInterface(stream) + .getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + } + + if (orientation != ExifInterface.ORIENTATION_NORMAL && + orientation != ExifInterface.ORIENTATION_UNDEFINED) { + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.postRotate(90f) + matrix.preScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.postRotate(270f) + matrix.preScale(-1f, 1f) + } + } + + val rotated = + Bitmap.createBitmap( + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + matrix, + true + ) + if (rotated != bitmap) { + bitmap.recycle() + bitmap = rotated + } + } + + bitmap } catch (e: Exception) { null } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index fa0f390..22a6dbb 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -502,51 +502,66 @@ fun MessageBubble( val configuration = LocalConfiguration.current val screenWidthDp = configuration.screenWidthDp val maxPhotoWidth = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp).dp + val maxCollageWidth = TelegramBubbleSpec.maxCollageWidth(screenWidthDp).dp + val imageCount = + message.attachments.count { + it.type == + com.rosetta.messenger.network.AttachmentType.IMAGE + } + val isImageCollage = imageCount > 1 // Вычисляем ширину фото для ограничения пузырька val photoWidth = if (hasImageWithCaption || hasOnlyMedia) { - val firstImage = - message.attachments.firstOrNull { - it.type == - com.rosetta.messenger.network - .AttachmentType.IMAGE - } - if (firstImage != null && - firstImage.width > 0 && - firstImage.height > 0 - ) { - val ar = - firstImage.width.toFloat() / - firstImage.height.toFloat() - val maxW = - TelegramBubbleSpec.maxPhotoWidth( - screenWidthDp - ) - .toFloat() - var w = if (ar >= 1f) maxW else maxW * 0.75f - var h = w / ar - - if (h > TelegramBubbleSpec.maxPhotoHeight) { - h = - TelegramBubbleSpec.maxPhotoHeight - .toFloat() - w = h * ar - } - if (h < TelegramBubbleSpec.minPhotoHeight) { - h = - TelegramBubbleSpec.minPhotoHeight - .toFloat() - w = h * ar - } - w.coerceIn( - TelegramBubbleSpec.minPhotoWidth - .toFloat(), - maxW - ) - .dp + if (isImageCollage) { + maxCollageWidth } else { - maxPhotoWidth + val firstImage = + message.attachments.firstOrNull { + it.type == + com.rosetta.messenger + .network + .AttachmentType + .IMAGE + } + if (firstImage != null && + firstImage.width > 0 && + firstImage.height > 0 + ) { + val ar = + firstImage.width.toFloat() / + firstImage.height + .toFloat() + val maxW = + TelegramBubbleSpec.maxPhotoWidth( + screenWidthDp + ) + .toFloat() + var w = if (ar >= 1f) maxW else maxW * 0.75f + var h = w / ar + + if (h > TelegramBubbleSpec.maxPhotoHeight) { + h = + TelegramBubbleSpec.maxPhotoHeight + .toFloat() + w = h * ar + } + if (h < TelegramBubbleSpec.minPhotoHeight) { + h = + TelegramBubbleSpec.minPhotoHeight + .toFloat() + w = h * ar + } + w.coerceIn( + TelegramBubbleSpec + .minPhotoWidth + .toFloat(), + maxW + ) + .dp + } else { + maxPhotoWidth + } } } else { 280.dp diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index 35bd490..e12fe0f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -71,6 +71,8 @@ import ja.burhanrashid52.photoeditor.PhotoEditorView import ja.burhanrashid52.photoeditor.SaveSettings import java.io.File import kotlin.coroutines.resume +import kotlin.math.abs +import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -1240,8 +1242,18 @@ private suspend fun saveEditedImageOld( ) } - // Возвращаем сохраненный файл без ручного кропа, чтобы не резать изображение - onResult(savedPath?.let { Uri.fromFile(File(it)) } ?: imageUri) + val rawResultUri = savedPath?.let { Uri.fromFile(File(it)) } ?: imageUri + val normalizedUri = + if (rawResultUri != imageUri) { + removeLetterboxFromEditedImage( + context = context, + editedUri = rawResultUri, + sourceUri = imageUri + ) ?: rawResultUri + } else { + rawResultUri + } + onResult(normalizedUri) } catch (e: Exception) { onResult(imageUri) } @@ -1303,12 +1315,131 @@ private suspend fun saveEditedImageSyncOld( ) } - savedPath?.let { Uri.fromFile(File(it)) } ?: imageUri + val rawResultUri = savedPath?.let { Uri.fromFile(File(it)) } ?: imageUri + if (rawResultUri == imageUri) { + rawResultUri + } else { + removeLetterboxFromEditedImage( + context = context, + editedUri = rawResultUri, + sourceUri = imageUri + ) ?: rawResultUri + } } catch (e: Exception) { imageUri } } +/** + * Removes black/transparent letterbox bars produced by PhotoEditor fullscreen save + * by cropping to the source image aspect ratio inside edited bitmap bounds. + */ +private suspend fun removeLetterboxFromEditedImage( + context: Context, + editedUri: Uri, + sourceUri: Uri +): Uri? = withContext(Dispatchers.IO) { + runCatching { + val editedFile = File(editedUri.path ?: return@runCatching null) + if (!editedFile.exists()) return@runCatching null + + val editedBitmap = BitmapFactory.decodeFile(editedFile.absolutePath) ?: return@runCatching null + val editedWidth = editedBitmap.width + val editedHeight = editedBitmap.height + if (editedWidth <= 0 || editedHeight <= 0) { + editedBitmap.recycle() + return@runCatching null + } + + val (sourceWidth, sourceHeight) = getOrientedImageDimensions(context, sourceUri) + if (sourceWidth <= 0 || sourceHeight <= 0) { + editedBitmap.recycle() + return@runCatching null + } + + val scale = minOf( + editedWidth / sourceWidth.toFloat(), + editedHeight / sourceHeight.toFloat() + ) + val expectedContentWidth = (sourceWidth * scale).roundToInt().coerceAtLeast(1) + val expectedContentHeight = (sourceHeight * scale).roundToInt().coerceAtLeast(1) + + val left = ((editedWidth - expectedContentWidth) / 2f).roundToInt().coerceAtLeast(0) + val top = ((editedHeight - expectedContentHeight) / 2f).roundToInt().coerceAtLeast(0) + val cropWidth = expectedContentWidth.coerceAtMost(editedWidth - left) + val cropHeight = expectedContentHeight.coerceAtMost(editedHeight - top) + + // Nothing to crop + if ( + left <= 1 && + top <= 1 && + abs(cropWidth - editedWidth) <= 1 && + abs(cropHeight - editedHeight) <= 1 + ) { + editedBitmap.recycle() + return@runCatching editedUri + } + + // Safety guard against invalid/asymmetric crop. + if (cropWidth < editedWidth / 3 || cropHeight < editedHeight / 3) { + editedBitmap.recycle() + return@runCatching editedUri + } + + val cropped = + Bitmap.createBitmap( + editedBitmap, + left, + top, + cropWidth.coerceAtMost(editedBitmap.width - left), + cropHeight.coerceAtMost(editedBitmap.height - top) + ) + editedBitmap.recycle() + + val normalizedFile = + File( + context.cacheDir, + "edited_normalized_${System.currentTimeMillis()}_${(0..9999).random()}.png" + ) + normalizedFile.outputStream().use { out -> + cropped.compress(Bitmap.CompressFormat.PNG, 100, out) + out.flush() + } + cropped.recycle() + + Uri.fromFile(normalizedFile) + }.getOrNull() +} + +private fun getOrientedImageDimensions(context: Context, uri: Uri): Pair { + return try { + val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { input -> + BitmapFactory.decodeStream(input, null, boundsOptions) + } + + var width = boundsOptions.outWidth + var height = boundsOptions.outHeight + if (width <= 0 || height <= 0) return Pair(0, 0) + + val orientation = readExifOrientation(context, uri) + val needsSwap = + orientation == ExifInterface.ORIENTATION_ROTATE_90 || + orientation == ExifInterface.ORIENTATION_ROTATE_270 || + orientation == ExifInterface.ORIENTATION_TRANSPOSE || + orientation == ExifInterface.ORIENTATION_TRANSVERSE + + if (needsSwap) { + val temp = width + width = height + height = temp + } + Pair(width, height) + } catch (_: Exception) { + Pair(0, 0) + } +} + /** Launch UCrop activity */ private fun launchCrop( context: Context, @@ -1590,24 +1721,6 @@ fun MultiImageEditorScreen( ) } - // Page indicator - if (imagesWithCaptions.size > 1) { - Box( - modifier = Modifier - .align(Alignment.Center) - .clip(RoundedCornerShape(12.dp)) - .background(Color.Black.copy(alpha = 0.5f)) - .padding(horizontal = 12.dp, vertical = 6.dp) - ) { - Text( - text = "${pagerState.currentPage + 1} / ${imagesWithCaptions.size}", - color = Color.White, - fontSize = 14.sp, - fontWeight = FontWeight.Medium - ) - } - } - if (currentTool == EditorTool.DRAW) { IconButton( onClick = { photoEditors[pagerState.currentPage]?.undo() }, diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt index c453a52..5ec0bb7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt @@ -145,6 +145,37 @@ private val GPU_ALPHA_THRESHOLD_MATRIX = floatArrayOf( */ private const val BLACK_BAR_HEIGHT_DP = 32f +private fun isCenteredTopCutout( + notchInfo: NotchInfoUtils.NotchInfo?, + screenWidthPx: Float +): Boolean { + if (notchInfo == null || notchInfo.bounds.width() <= 0f || notchInfo.bounds.height() <= 0f) { + return false + } + val tolerancePx = screenWidthPx * 0.20f + return abs(notchInfo.bounds.centerX() - screenWidthPx / 2f) <= tolerancePx +} + +private fun resolveSafeNotchCenterY( + notchInfo: NotchInfoUtils.NotchInfo?, + hasCenteredCutout: Boolean, + statusBarHeightPx: Float, + fallbackCenterY: Float +): Float { + if (!hasCenteredCutout || notchInfo == null) return fallbackCenterY + + val rawCenterY = if (notchInfo.isLikelyCircle) { + notchInfo.bounds.bottom - min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f + } else { + notchInfo.bounds.centerY() + } + + // Some OEM cutout specs report unstable Y; keep collapse target in status-bar safe zone. + val minSafeY = max(statusBarHeightPx * 0.45f, fallbackCenterY) + val maxSafeY = max(statusBarHeightPx * 0.95f, minSafeY + 1f) + return rawCenterY.coerceIn(minSafeY, maxSafeY) +} + /** * State for avatar position and size during animation * Like Telegram's ProfileMetaballView state variables @@ -399,9 +430,13 @@ fun ProfileMetaballOverlay( } } + val hasCenteredNotch = remember(notchInfo, screenWidthPx) { + !MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx) + } + // Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView) - val notchRadiusPx = remember(notchInfo) { - if (notchInfo != null && notchInfo.gravity == Gravity.CENTER) { + val notchRadiusPx = remember(notchInfo, hasCenteredNotch) { + if (hasCenteredNotch && notchInfo != null) { if (notchInfo.isLikelyCircle) { // Circular camera cutout - use actual width/height min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f @@ -417,8 +452,8 @@ fun ProfileMetaballOverlay( // Notch center position - ONLY use if notch is centered (like front camera) // If notch is off-center (corner notch), use screen center instead - val notchCenterX = remember(notchInfo, screenWidthPx) { - if (notchInfo != null && notchInfo.gravity == Gravity.CENTER) { + val notchCenterX = remember(notchInfo, screenWidthPx, hasCenteredNotch) { + if (hasCenteredNotch && notchInfo != null) { // Centered notch (like Dynamic Island or punch-hole camera) notchInfo.bounds.centerX() } else { @@ -427,15 +462,13 @@ fun ProfileMetaballOverlay( } } - val notchCenterY = remember(notchInfo) { - if (notchInfo != null && notchInfo.isLikelyCircle) { - // For circle: center is at bottom - width/2 (like Telegram) - notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f - } else if (notchInfo != null) { - notchInfo.bounds.centerY() - } else { - statusBarHeightPx / 2f - } + val notchCenterY = remember(notchInfo, hasCenteredNotch, statusBarHeightPx) { + resolveSafeNotchCenterY( + notchInfo = notchInfo, + hasCenteredCutout = hasCenteredNotch, + statusBarHeightPx = statusBarHeightPx, + fallbackCenterY = statusBarHeightPx / 2f + ) } // Scale Telegram thresholds to our bigger base avatar size (120dp vs 96dp). @@ -492,8 +525,7 @@ fun ProfileMetaballOverlay( val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } // Detect if device has a real centered notch (debug override supported) - val hasRealNotch = !MetaballDebug.forceNoNotch && - notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0 + val hasRealNotch = hasCenteredNotch // Calculate "v" parameter - thickness of connector based on distance val distance = avatarState.centerY - notchCenterY @@ -895,8 +927,9 @@ fun ProfileMetaballOverlayCpu( val notchInfo = remember(view) { NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view) } - val hasRealNotch = !MetaballDebug.forceNoNotch && - notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0 + val hasRealNotch = remember(notchInfo, screenWidthPx) { + !MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx) + } val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } val notchRadiusPx = remember(notchInfo) { @@ -913,14 +946,13 @@ fun ProfileMetaballOverlayCpu( val notchCenterX = remember(notchInfo, screenWidthPx) { if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerX() else screenWidthPx / 2f } - val notchCenterY = remember(notchInfo) { - if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { - notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f - } else if (hasRealNotch && notchInfo != null) { - notchInfo.bounds.centerY() - } else { - blackBarHeightPx / 2f - } + val notchCenterY = remember(notchInfo, hasRealNotch, statusBarHeightPx, blackBarHeightPx) { + resolveSafeNotchCenterY( + notchInfo = notchInfo, + hasCenteredCutout = hasRealNotch, + statusBarHeightPx = statusBarHeightPx, + fallbackCenterY = blackBarHeightPx / 2f + ) } val thresholdScale = (avatarSizeExpandedPx / with(density) { 96.dp.toPx() }).coerceIn(1f, 1.35f) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 378607e..f4dca40 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -26,6 +27,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -34,7 +37,6 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.Block import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -160,12 +162,13 @@ private fun calculateAverageColor(bitmap: android.graphics.Bitmap): Color { ) } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) @Composable fun OtherProfileScreen( user: SearchUser, isDarkTheme: Boolean, onBack: () -> Unit, + onSwipeBackEnabledChanged: (Boolean) -> Unit = {}, avatarRepository: AvatarRepository? = null, currentUserPublicKey: String = "", currentUserPrivateKey: String = "", @@ -173,9 +176,19 @@ fun OtherProfileScreen( ) { var isBlocked by remember { mutableStateOf(false) } var showAvatarMenu by remember { mutableStateOf(false) } - var selectedTab by rememberSaveable { mutableStateOf(OtherProfileTab.MEDIA) } var showImageViewer by remember { mutableStateOf(false) } var imageViewerInitialIndex by remember { mutableIntStateOf(0) } + val tabs = remember { OtherProfileTab.entries } + val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabs.size }) + val selectedTab = + tabs.getOrElse(pagerState.currentPage.coerceIn(0, tabs.lastIndex)) { + OtherProfileTab.MEDIA + } + val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp + val sharedPagerMinHeight = (screenHeightDp * 0.45f).coerceAtLeast(240.dp) + LaunchedEffect(selectedTab) { + onSwipeBackEnabledChanged(selectedTab == OtherProfileTab.MEDIA) + } val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val avatarColors = getAvatarColor(user.publicKey, isDarkTheme) @@ -535,62 +548,78 @@ fun OtherProfileScreen( OtherProfileSharedTabs( selectedTab = selectedTab, - onTabSelected = { selectedTab = it }, + onTabSelected = { tab -> + val targetPage = tab.ordinal + if (pagerState.currentPage != targetPage) { + coroutineScope.launch { + pagerState.animateScrollToPage(targetPage) + } + } + }, isDarkTheme = isDarkTheme ) Spacer(modifier = Modifier.height(10.dp)) - OtherProfileSharedTabContent( - selectedTab = selectedTab, - sharedContent = sharedContent, - isDarkTheme = isDarkTheme, - accountPublicKey = activeAccountPublicKey, - accountPrivateKey = activeAccountPrivateKey, - onMediaClick = { index -> - imageViewerInitialIndex = index - showImageViewer = true - }, - onFileClick = { file -> - val opened = openSharedFile(context, file) - if (!opened) { - Toast.makeText( - context, - "File is not available on this device", - Toast.LENGTH_SHORT - ) - .show() - } - }, - onLinkClick = { link -> - val normalizedLink = - if (link.startsWith("http://", ignoreCase = true) || - link.startsWith("https://", ignoreCase = true) - ) { - link - } else { - "https://$link" - } - val opened = - runCatching { - context.startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse(normalizedLink) - ) + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth().heightIn(min = sharedPagerMinHeight), + beyondBoundsPageCount = 0, + verticalAlignment = Alignment.Top + ) { page -> + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopStart) { + OtherProfileSharedTabContent( + selectedTab = tabs[page], + sharedContent = sharedContent, + isDarkTheme = isDarkTheme, + accountPublicKey = activeAccountPublicKey, + accountPrivateKey = activeAccountPrivateKey, + onMediaClick = { index -> + imageViewerInitialIndex = index + showImageViewer = true + }, + onFileClick = { file -> + val opened = openSharedFile(context, file) + if (!opened) { + Toast.makeText( + context, + "File is not available on this device", + Toast.LENGTH_SHORT ) + .show() + } + }, + onLinkClick = { link -> + val normalizedLink = + if (link.startsWith("http://", ignoreCase = true) || + link.startsWith("https://", ignoreCase = true) + ) { + link + } else { + "https://$link" } - .isSuccess - if (!opened) { - Toast.makeText( - context, - "Unable to open this link", - Toast.LENGTH_SHORT - ) - .show() - } - } - ) + val opened = + runCatching { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse(normalizedLink) + ) + ) + } + .isSuccess + if (!opened) { + Toast.makeText( + context, + "Unable to open this link", + Toast.LENGTH_SHORT + ) + .show() + } + } + ) + } + } Spacer(modifier = Modifier.height(32.dp)) }