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(
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() },
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user