feat: implement avatar animation and enhance image sharing functionality

This commit is contained in:
2026-02-10 00:06:41 +05:00
parent 3c37a3b0e5
commit bbaa04cda5
10 changed files with 523 additions and 168 deletions

View File

@@ -918,16 +918,25 @@ fun MainScreen(
) )
} }
var isOtherProfileSwipeEnabled by remember { mutableStateOf(true) }
LaunchedEffect(selectedOtherUser?.publicKey) {
isOtherProfileSwipeEnabled = true
}
SwipeBackContainer( SwipeBackContainer(
isVisible = selectedOtherUser != null, isVisible = selectedOtherUser != null,
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } }, onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
swipeEnabled = isOtherProfileSwipeEnabled
) { ) {
selectedOtherUser?.let { currentOtherUser -> selectedOtherUser?.let { currentOtherUser ->
OtherProfileScreen( OtherProfileScreen(
user = currentOtherUser, user = currentOtherUser,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } }, onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
onSwipeBackEnabledChanged = { enabled ->
isOtherProfileSwipeEnabled = enabled
},
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
currentUserPublicKey = accountPublicKey, currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey currentUserPrivateKey = accountPrivateKey

View File

@@ -2283,13 +2283,15 @@ fun ChatDetailScreen(
inputFocusTrigger++ inputFocusTrigger++
}, },
onSendAll = { imagesWithCaptions -> onSendAll = { imagesWithCaptions ->
// 🚀 Мгновенный optimistic UI для каждого фото val imageUris = imagesWithCaptions.map { it.uri }
for (imageWithCaption in imagesWithCaptions) { val groupCaption =
viewModel.sendImageFromUri( imagesWithCaptions
imageWithCaption.uri, .firstOrNull { it.caption.isNotBlank() }
imageWithCaption.caption ?.caption
) ?.trim()
} .orEmpty()
viewModel.sendImageGroupFromUris(imageUris, groupCaption)
showMediaPicker = false showMediaPicker = false
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,

View File

@@ -2177,6 +2177,55 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val height: Int = 0 val height: Int = 0
) )
/**
* 🖼️ Отправка группы изображений из URI как одного media-group сообщения.
* Нужна для корректного коллажа у получателя (а не отдельных фото-сообщений).
*/
fun sendImageGroupFromUris(imageUris: List<android.net.Uri>, caption: String = "") {
if (imageUris.isEmpty()) return
if (imageUris.size == 1) {
sendImageFromUri(imageUris.first(), caption)
return
}
val context = getApplication<Application>()
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<ImageData>, caption: String = "") { fun sendImageGroup(images: List<ImageData>, caption: String = "") {
if (images.isEmpty()) return if (images.isEmpty()) return

View File

@@ -145,7 +145,7 @@ fun getAvatarText(publicKey: String): String {
return publicKey.take(2).uppercase() return publicKey.take(2).uppercase()
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun ChatsListScreen( fun ChatsListScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
@@ -1204,7 +1204,20 @@ fun ChatsListScreen(
} }
} }
Column { Column(
modifier =
Modifier.animateItemPlacement(
animationSpec =
spring(
dampingRatio =
Spring
.DampingRatioNoBouncy,
stiffness =
Spring
.StiffnessLow
)
)
) {
SwipeableDialogItem( SwipeableDialogItem(
dialog = dialog =
dialog, dialog,
@@ -1802,12 +1815,18 @@ fun SwipeableDialogItem(
isPinned: Boolean = false, isPinned: Boolean = false,
onPin: () -> Unit = {} onPin: () -> Unit = {}
) { ) {
val backgroundColor = val targetBackgroundColor =
if (isPinned) { if (isPinned) {
if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED) if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED)
} else { } else {
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) 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) } var offsetX by remember { mutableStateOf(0f) }
// 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete) // 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete)
val buttonCount = if (isSavedMessages) 2 else 3 val buttonCount = if (isSavedMessages) 2 else 3
@@ -2551,15 +2570,37 @@ fun DialogItemContent(
} }
} }
// 📌 Pin icon // 📌 Pin icon with smooth in/out animation
if (isPinned) { AnimatedVisibility(
Spacer(modifier = Modifier.width(6.dp)) visible = isPinned,
Icon( enter =
imageVector = TablerIcons.Pin, fadeIn(animationSpec = tween(170)) +
contentDescription = "Pinned", scaleIn(
tint = secondaryTextColor.copy(alpha = 0.5f), initialScale = 0.72f,
modifier = Modifier.size(16.dp) 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)
)
}
} }
} }
} }

View File

@@ -84,6 +84,9 @@ fun SearchScreen(
val searchResults by searchViewModel.searchResults.collectAsState() val searchResults by searchViewModel.searchResults.collectAsState()
val isSearching by searchViewModel.isSearching.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 - отложенная подписка // Recent users - отложенная подписка
val recentUsers by RecentSearchesManager.recentUsers.collectAsState() val recentUsers by RecentSearchesManager.recentUsers.collectAsState()
@@ -111,6 +114,7 @@ fun SearchScreen(
// 🔥 Обработка системного жеста Back // 🔥 Обработка системного жеста Back
BackHandler { BackHandler {
hideKeyboardInstantly() hideKeyboardInstantly()
searchViewModel.clearSearchQuery()
onBackClick() onBackClick()
} }
@@ -155,6 +159,7 @@ fun SearchScreen(
IconButton( IconButton(
onClick = { onClick = {
hideKeyboardInstantly() hideKeyboardInstantly()
searchViewModel.clearSearchQuery()
onBackClick() onBackClick()
} }
) { ) {

View File

@@ -53,6 +53,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import java.io.ByteArrayInputStream
import kotlin.math.min import kotlin.math.min
private const val TAG = "AttachmentComponents" private const val TAG = "AttachmentComponents"
@@ -148,6 +149,8 @@ object TelegramBubbleSpec {
// maxWidth = min(AndroidUtilities.displaySize.x, AndroidUtilities.displaySize.y) * 0.5f // maxWidth = min(AndroidUtilities.displaySize.x, AndroidUtilities.displaySize.y) * 0.5f
// Telegram использует ~50-65% ширины экрана // Telegram использует ~50-65% ширины экрана
fun maxPhotoWidth(screenWidth: Int): Int = (screenWidth * 0.65f).toInt() 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 maxPhotoHeight = 360 // dp, примерно maxWidth + 100
val minPhotoWidth = 150 // dp val minPhotoWidth = 150 // dp
val minPhotoHeight = 120 // dp val minPhotoHeight = 120 // dp
@@ -166,7 +169,7 @@ object TelegramBubbleSpec {
val timeTextSize = 11.sp val timeTextSize = 11.sp
// === COLLAGE === // === COLLAGE ===
val collageSpacing = 2.dp // Отступ между фото в коллаже val collageSpacing = 1.dp // Тонкий разделитель как в Telegram
// === TEXT === // === TEXT ===
val messageTextSize = 16.sp val messageTextSize = 16.sp
@@ -292,6 +295,12 @@ fun ImageCollage(
) { ) {
val count = attachments.size val count = attachments.size
val spacing = TelegramBubbleSpec.collageSpacing 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 // Показываем время и статус только если нет caption
val showOverlayOnLast = !hasCaption val showOverlayOnLast = !hasCaption
@@ -311,7 +320,7 @@ fun ImageCollage(
RoundedCornerShape(TelegramBubbleSpec.photoRadius) RoundedCornerShape(TelegramBubbleSpec.photoRadius)
} }
Box(modifier = modifier.clip(collageShape)) { Box(modifier = modifier.clip(collageShape).background(collageDividerColor)) {
when (count) { when (count) {
1 -> { 1 -> {
// Одно фото - размер определяется самим фото (Telegram style) // Одно фото - размер определяется самим фото (Telegram style)
@@ -861,8 +870,10 @@ fun ImageAttachment(
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val screenWidthDp = configuration.screenWidthDp val screenWidthDp = configuration.screenWidthDp
val actualWidth = if (attachment.width > 0) attachment.width else imageBitmap?.width ?: 0 // For incoming media where server dimensions are often missing, keep stable bubble-based fallback size
val actualHeight = if (attachment.height > 0) attachment.height else imageBitmap?.height ?: 0 // 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) = val (imageWidth, imageHeight) =
remember(actualWidth, actualHeight, fillMaxSize, aspectRatio, screenWidthDp) { remember(actualWidth, actualHeight, fillMaxSize, aspectRatio, screenWidthDp) {
@@ -871,10 +882,6 @@ fun ImageAttachment(
} else { } else {
// Telegram: maxWidth = screenWidth * 0.65 // Telegram: maxWidth = screenWidth * 0.65
val maxPhotoWidthPx = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp) 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) { if (actualWidth > 0 && actualHeight > 0) {
val ar = actualWidth.toFloat() / actualHeight.toFloat() val ar = actualWidth.toFloat() / actualHeight.toFloat()
@@ -911,7 +918,14 @@ fun ImageAttachment(
w.dp to h.dp w.dp to h.dp
} else { } 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 base64
} }
val bytes = Base64.decode(cleanBase64, Base64.DEFAULT) 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) { } catch (e: Exception) {
null null
} }

View File

@@ -502,51 +502,66 @@ fun MessageBubble(
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val screenWidthDp = configuration.screenWidthDp val screenWidthDp = configuration.screenWidthDp
val maxPhotoWidth = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp).dp 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 = val photoWidth =
if (hasImageWithCaption || hasOnlyMedia) { if (hasImageWithCaption || hasOnlyMedia) {
val firstImage = if (isImageCollage) {
message.attachments.firstOrNull { maxCollageWidth
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 { } 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 { } else {
280.dp 280.dp

View File

@@ -71,6 +71,8 @@ import ja.burhanrashid52.photoeditor.PhotoEditorView
import ja.burhanrashid52.photoeditor.SaveSettings import ja.burhanrashid52.photoeditor.SaveSettings
import java.io.File import java.io.File
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -1240,8 +1242,18 @@ private suspend fun saveEditedImageOld(
) )
} }
// Возвращаем сохраненный файл без ручного кропа, чтобы не резать изображение val rawResultUri = savedPath?.let { Uri.fromFile(File(it)) } ?: imageUri
onResult(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) { } catch (e: Exception) {
onResult(imageUri) 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) { } catch (e: Exception) {
imageUri 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<Int, Int> {
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 */ /** Launch UCrop activity */
private fun launchCrop( private fun launchCrop(
context: Context, 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) { if (currentTool == EditorTool.DRAW) {
IconButton( IconButton(
onClick = { photoEditors[pagerState.currentPage]?.undo() }, onClick = { photoEditors[pagerState.currentPage]?.undo() },

View File

@@ -145,6 +145,37 @@ private val GPU_ALPHA_THRESHOLD_MATRIX = floatArrayOf(
*/ */
private const val BLACK_BAR_HEIGHT_DP = 32f 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 * State for avatar position and size during animation
* Like Telegram's ProfileMetaballView state variables * 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) // Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
val notchRadiusPx = remember(notchInfo) { val notchRadiusPx = remember(notchInfo, hasCenteredNotch) {
if (notchInfo != null && notchInfo.gravity == Gravity.CENTER) { if (hasCenteredNotch && notchInfo != null) {
if (notchInfo.isLikelyCircle) { if (notchInfo.isLikelyCircle) {
// Circular camera cutout - use actual width/height // Circular camera cutout - use actual width/height
min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f 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) // Notch center position - ONLY use if notch is centered (like front camera)
// If notch is off-center (corner notch), use screen center instead // If notch is off-center (corner notch), use screen center instead
val notchCenterX = remember(notchInfo, screenWidthPx) { val notchCenterX = remember(notchInfo, screenWidthPx, hasCenteredNotch) {
if (notchInfo != null && notchInfo.gravity == Gravity.CENTER) { if (hasCenteredNotch && notchInfo != null) {
// Centered notch (like Dynamic Island or punch-hole camera) // Centered notch (like Dynamic Island or punch-hole camera)
notchInfo.bounds.centerX() notchInfo.bounds.centerX()
} else { } else {
@@ -427,15 +462,13 @@ fun ProfileMetaballOverlay(
} }
} }
val notchCenterY = remember(notchInfo) { val notchCenterY = remember(notchInfo, hasCenteredNotch, statusBarHeightPx) {
if (notchInfo != null && notchInfo.isLikelyCircle) { resolveSafeNotchCenterY(
// For circle: center is at bottom - width/2 (like Telegram) notchInfo = notchInfo,
notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f hasCenteredCutout = hasCenteredNotch,
} else if (notchInfo != null) { statusBarHeightPx = statusBarHeightPx,
notchInfo.bounds.centerY() fallbackCenterY = statusBarHeightPx / 2f
} else { )
statusBarHeightPx / 2f
}
} }
// Scale Telegram thresholds to our bigger base avatar size (120dp vs 96dp). // 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() } val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
// Detect if device has a real centered notch (debug override supported) // Detect if device has a real centered notch (debug override supported)
val hasRealNotch = !MetaballDebug.forceNoNotch && val hasRealNotch = hasCenteredNotch
notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0
// Calculate "v" parameter - thickness of connector based on distance // Calculate "v" parameter - thickness of connector based on distance
val distance = avatarState.centerY - notchCenterY val distance = avatarState.centerY - notchCenterY
@@ -895,8 +927,9 @@ fun ProfileMetaballOverlayCpu(
val notchInfo = remember(view) { val notchInfo = remember(view) {
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view) NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
} }
val hasRealNotch = !MetaballDebug.forceNoNotch && val hasRealNotch = remember(notchInfo, screenWidthPx) {
notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0 !MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx)
}
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
val notchRadiusPx = remember(notchInfo) { val notchRadiusPx = remember(notchInfo) {
@@ -913,14 +946,13 @@ fun ProfileMetaballOverlayCpu(
val notchCenterX = remember(notchInfo, screenWidthPx) { val notchCenterX = remember(notchInfo, screenWidthPx) {
if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerX() else screenWidthPx / 2f if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerX() else screenWidthPx / 2f
} }
val notchCenterY = remember(notchInfo) { val notchCenterY = remember(notchInfo, hasRealNotch, statusBarHeightPx, blackBarHeightPx) {
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { resolveSafeNotchCenterY(
notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f notchInfo = notchInfo,
} else if (hasRealNotch && notchInfo != null) { hasCenteredCutout = hasRealNotch,
notchInfo.bounds.centerY() statusBarHeightPx = statusBarHeightPx,
} else { fallbackCenterY = blackBarHeightPx / 2f
blackBarHeightPx / 2f )
}
} }
val thresholdScale = (avatarSizeExpandedPx / with(density) { 96.dp.toPx() }).coerceIn(1f, 1.35f) val thresholdScale = (avatarSizeExpandedPx / with(density) { 96.dp.toPx() }).coerceIn(1f, 1.35f)

View File

@@ -17,6 +17,7 @@ import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.material.icons.outlined.Block
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier 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 @Composable
fun OtherProfileScreen( fun OtherProfileScreen(
user: SearchUser, user: SearchUser,
isDarkTheme: Boolean, isDarkTheme: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onSwipeBackEnabledChanged: (Boolean) -> Unit = {},
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
currentUserPrivateKey: String = "", currentUserPrivateKey: String = "",
@@ -173,9 +176,19 @@ fun OtherProfileScreen(
) { ) {
var isBlocked by remember { mutableStateOf(false) } var isBlocked by remember { mutableStateOf(false) }
var showAvatarMenu by remember { mutableStateOf(false) } var showAvatarMenu by remember { mutableStateOf(false) }
var selectedTab by rememberSaveable { mutableStateOf(OtherProfileTab.MEDIA) }
var showImageViewer by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) }
var imageViewerInitialIndex by remember { mutableIntStateOf(0) } 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 backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val avatarColors = getAvatarColor(user.publicKey, isDarkTheme) val avatarColors = getAvatarColor(user.publicKey, isDarkTheme)
@@ -535,62 +548,78 @@ fun OtherProfileScreen(
OtherProfileSharedTabs( OtherProfileSharedTabs(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it }, onTabSelected = { tab ->
val targetPage = tab.ordinal
if (pagerState.currentPage != targetPage) {
coroutineScope.launch {
pagerState.animateScrollToPage(targetPage)
}
}
},
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
OtherProfileSharedTabContent( HorizontalPager(
selectedTab = selectedTab, state = pagerState,
sharedContent = sharedContent, modifier = Modifier.fillMaxWidth().heightIn(min = sharedPagerMinHeight),
isDarkTheme = isDarkTheme, beyondBoundsPageCount = 0,
accountPublicKey = activeAccountPublicKey, verticalAlignment = Alignment.Top
accountPrivateKey = activeAccountPrivateKey, ) { page ->
onMediaClick = { index -> Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopStart) {
imageViewerInitialIndex = index OtherProfileSharedTabContent(
showImageViewer = true selectedTab = tabs[page],
}, sharedContent = sharedContent,
onFileClick = { file -> isDarkTheme = isDarkTheme,
val opened = openSharedFile(context, file) accountPublicKey = activeAccountPublicKey,
if (!opened) { accountPrivateKey = activeAccountPrivateKey,
Toast.makeText( onMediaClick = { index ->
context, imageViewerInitialIndex = index
"File is not available on this device", showImageViewer = true
Toast.LENGTH_SHORT },
) onFileClick = { file ->
.show() val opened = openSharedFile(context, file)
} if (!opened) {
}, Toast.makeText(
onLinkClick = { link -> context,
val normalizedLink = "File is not available on this device",
if (link.startsWith("http://", ignoreCase = true) || Toast.LENGTH_SHORT
link.startsWith("https://", ignoreCase = true)
) {
link
} else {
"https://$link"
}
val opened =
runCatching {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(normalizedLink)
)
) )
.show()
}
},
onLinkClick = { link ->
val normalizedLink =
if (link.startsWith("http://", ignoreCase = true) ||
link.startsWith("https://", ignoreCase = true)
) {
link
} else {
"https://$link"
} }
.isSuccess val opened =
if (!opened) { runCatching {
Toast.makeText( context.startActivity(
context, Intent(
"Unable to open this link", Intent.ACTION_VIEW,
Toast.LENGTH_SHORT Uri.parse(normalizedLink)
) )
.show() )
} }
} .isSuccess
) if (!opened) {
Toast.makeText(
context,
"Unable to open this link",
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
}
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
} }