feat: implement avatar animation and enhance image sharing functionality
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user