feat: implement avatar animation and enhance image sharing functionality

This commit is contained in:
k1ngsterr1
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(
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

View File

@@ -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,

View File

@@ -2177,6 +2177,55 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
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 = "") {
if (images.isEmpty()) return

View File

@@ -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)
)
}
}
}
}

View File

@@ -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()
}
) {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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<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 */
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() },

View File

@@ -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)

View File

@@ -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))
}