feat: Implement multi-image editing with captions in ChatDetailScreen and enhance ProfileScreen with overscroll effects

This commit is contained in:
k1ngsterr1
2026-01-30 02:59:43 +05:00
parent 6720057ebc
commit 7691926ef6
5 changed files with 681 additions and 225 deletions

View File

@@ -73,6 +73,8 @@ import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.ui.chats.ForwardChatPickerBottomSheet
import com.rosetta.messenger.utils.MediaUtils
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
import com.rosetta.messenger.ui.chats.components.ImageWithCaption
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import androidx.compose.runtime.collectAsState
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -188,7 +190,10 @@ fun ChatDetailScreen(
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка
var pendingCameraPhotoUri by remember { mutableStateOf<Uri?>(null) } // Фото для редактирования
// 📷 Camera launcher
// <EFBFBD> Состояние для multi-image editor (галерея)
var pendingGalleryImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
// <20>📷 Camera launcher
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success ->
@@ -1988,31 +1993,16 @@ fun ChatDetailScreen(
isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey,
onMediaSelected = { selectedMedia ->
// 📸 Отправляем выбранные изображения как коллаж (группу)
android.util.Log.d("ChatDetailScreen", "📸 Sending ${selectedMedia.size} media items as group")
scope.launch {
// Собираем все изображения
val imageDataList = mutableListOf<ChatViewModel.ImageData>()
// 📸 Открываем edit screen для выбранных изображений
android.util.Log.d("ChatDetailScreen", "📸 Opening editor for ${selectedMedia.size} media items")
for (item in selectedMedia) {
if (item.isVideo) {
// TODO: Поддержка видео
android.util.Log.d("ChatDetailScreen", "📹 Video not supported yet: ${item.uri}")
} else {
// Изображение
val base64 = MediaUtils.uriToBase64Image(context, item.uri)
val blurhash = MediaUtils.generateBlurhash(context, item.uri)
val (width, height) = MediaUtils.getImageDimensions(context, item.uri)
if (base64 != null) {
imageDataList.add(ChatViewModel.ImageData(base64, blurhash, width, height))
}
}
}
// Собираем URI изображений (пока без видео)
val imageUris = selectedMedia
.filter { !it.isVideo }
.map { it.uri }
// Отправляем группой (коллаж)
if (imageDataList.isNotEmpty()) {
viewModel.sendImageGroup(imageDataList)
}
if (imageUris.isNotEmpty()) {
pendingGalleryImages = imageUris
}
},
onOpenCamera = {
@@ -2078,4 +2068,45 @@ fun ChatDetailScreen(
showCaptionInput = true
)
}
// 📸 Multi-Image Editor для фото из галереи (со свайпом как в Telegram)
if (pendingGalleryImages.isNotEmpty()) {
MultiImageEditorScreen(
imageUris = pendingGalleryImages,
onDismiss = {
pendingGalleryImages = emptyList()
},
onSendAll = { imagesWithCaptions ->
pendingGalleryImages = emptyList()
scope.launch {
// Собираем все изображения с их caption
val imageDataList = mutableListOf<ChatViewModel.ImageData>()
for (imageWithCaption in imagesWithCaptions) {
val base64 = MediaUtils.uriToBase64Image(context, imageWithCaption.uri)
val blurhash = MediaUtils.generateBlurhash(context, imageWithCaption.uri)
val (width, height) = MediaUtils.getImageDimensions(context, imageWithCaption.uri)
if (base64 != null) {
imageDataList.add(ChatViewModel.ImageData(base64, blurhash, width, height))
}
}
// Если одно фото с caption - отправляем как обычное сообщение
if (imageDataList.size == 1 && imagesWithCaptions[0].caption.isNotBlank()) {
viewModel.sendImageMessage(
imageDataList[0].base64,
imageDataList[0].blurhash,
imagesWithCaptions[0].caption,
imageDataList[0].width,
imageDataList[0].height
)
} else if (imageDataList.isNotEmpty()) {
// Отправляем группой (коллаж)
viewModel.sendImageGroup(imageDataList)
}
}
},
isDarkTheme = isDarkTheme
)
}
}

View File

@@ -82,6 +82,7 @@ fun MessageAttachments(
messageStatus: MessageStatus = MessageStatus.READ,
avatarRepository: AvatarRepository? = null,
currentUserPublicKey: String = "",
hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото
onImageClick: (attachmentId: String) -> Unit = {},
modifier: Modifier = Modifier
) {
@@ -106,6 +107,7 @@ fun MessageAttachments(
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus,
hasCaption = hasCaption,
onImageClick = onImageClick
)
}
@@ -163,18 +165,26 @@ fun ImageCollage(
isDarkTheme: Boolean,
timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ,
hasCaption: Boolean = false, // Если есть caption - время показывается под фото
onImageClick: (attachmentId: String) -> Unit = {},
modifier: Modifier = Modifier
) {
val count = attachments.size
val spacing = 2.dp
// Показываем время и статус только на последнем изображении коллажа
val showOverlayOnLast = true
// Показываем время и статус только если нет caption
val showOverlayOnLast = !hasCaption
// Закругление: если есть caption - только сверху, снизу прямые углы
val collageShape = if (hasCaption) {
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 0.dp, bottomEnd = 0.dp)
} else {
RoundedCornerShape(16.dp)
}
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.clip(collageShape)
) {
when (count) {
1 -> {

View File

@@ -203,10 +203,10 @@ fun MessageBubble(
val bubbleShape = remember(message.isOutgoing, showTail) {
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (message.isOutgoing) 16.dp else (if (showTail) 4.dp else 16.dp),
bottomEnd = if (message.isOutgoing) (if (showTail) 4.dp else 16.dp) else 16.dp
topStart = 18.dp,
topEnd = 18.dp,
bottomStart = if (message.isOutgoing) 18.dp else (if (showTail) 4.dp else 18.dp),
bottomEnd = if (message.isOutgoing) (if (showTail) 4.dp else 18.dp) else 18.dp
)
}
@@ -404,6 +404,7 @@ fun MessageBubble(
messageStatus = message.status,
avatarRepository = avatarRepository,
currentUserPublicKey = currentUserPublicKey,
hasCaption = hasImageWithCaption, // Если есть caption - время на пузырьке, не на фото
onImageClick = onImageClick
)
}
@@ -414,7 +415,7 @@ fun MessageBubble(
modifier = Modifier
.fillMaxWidth()
.background(bubbleColor)
.padding(horizontal = 10.dp, vertical = 8.dp)
.padding(horizontal = 10.dp, vertical = 6.dp) // Уменьшил padding
) {
Row(
verticalAlignment = Alignment.Bottom,

View File

@@ -16,12 +16,17 @@ import androidx.core.content.FileProvider
import com.yalantis.ucrop.UCrop
import androidx.compose.animation.*
import androidx.compose.foundation.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
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.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@@ -30,7 +35,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -137,95 +144,14 @@ fun ImageEditorScreen(
.fillMaxSize()
.background(Color.Black)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Top toolbar
TopAppBar(
title = { },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(
TablerIcons.X,
contentDescription = "Close",
tint = Color.White
)
}
},
actions = {
// Undo
IconButton(
onClick = { photoEditor?.undo() }
) {
Icon(
TablerIcons.ArrowBackUp,
contentDescription = "Undo",
tint = Color.White
)
}
// Redo
IconButton(
onClick = { photoEditor?.redo() }
) {
Icon(
TablerIcons.ArrowForwardUp,
contentDescription = "Redo",
tint = Color.White
)
}
// Done/Save button (only show if no caption input)
if (!showCaptionInput) {
TextButton(
onClick = {
scope.launch {
isSaving = true
saveEditedImage(context, photoEditor) { savedUri ->
isSaving = false
if (savedUri != null) {
onSave(savedUri)
}
}
}
},
enabled = !isSaving
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = PrimaryBlue,
strokeWidth = 2.dp
)
} else {
Text(
"Done",
color = PrimaryBlue,
fontWeight = FontWeight.SemiBold
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
// Photo Editor View
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
// Photo Editor View - FULLSCREEN edge-to-edge
AndroidView(
factory = { ctx ->
PhotoEditorView(ctx).apply {
photoEditorView = this
// Load image - fullscreen
// Load image - fullscreen, CENTER_CROP чтобы заполнить экран
source.setImageURI(currentImageUri)
source.scaleType = ImageView.ScaleType.FIT_CENTER
source.scaleType = ImageView.ScaleType.CENTER_CROP
// Build PhotoEditor
photoEditor = PhotoEditor.Builder(ctx, this)
@@ -242,8 +168,55 @@ fun ImageEditorScreen(
},
modifier = Modifier.fillMaxSize()
)
// Top toolbar - OVERLAY (поверх фото)
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Close button
IconButton(onClick = onDismiss) {
Icon(
TablerIcons.X,
contentDescription = "Close",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
// Undo / Redo buttons
Row {
IconButton(onClick = { photoEditor?.undo() }) {
Icon(
TablerIcons.ArrowBackUp,
contentDescription = "Undo",
tint = Color.White,
modifier = Modifier.size(26.dp)
)
}
IconButton(onClick = { photoEditor?.redo() }) {
Icon(
TablerIcons.ArrowForwardUp,
contentDescription = "Redo",
tint = Color.White,
modifier = Modifier.size(26.dp)
)
}
}
}
// Bottom section - OVERLAY (Caption + Tools)
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.imePadding()
.navigationBarsPadding()
) {
// Color picker bar (when drawing)
AnimatedVisibility(
visible = currentTool == EditorTool.DRAW && showColorPicker,
@@ -289,9 +262,9 @@ fun ImageEditorScreen(
)
}
// Caption input bar (for camera flow like Telegram) - ВЫШЕ иконок
// Caption input bar (Telegram-style) - Beautiful overlay
if (showCaptionInput) {
CaptionInputBar(
TelegramCaptionInputBar(
caption = caption,
onCaptionChange = { caption = it },
isSaving = isSaving,
@@ -374,7 +347,87 @@ fun ImageEditorScreen(
}
/**
* Caption input bar with send button (like Telegram)
* Telegram-style Caption input bar with send button - Beautiful transparent overlay
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TelegramCaptionInputBar(
caption: String,
onCaptionChange: (String) -> Unit,
isSaving: Boolean,
onSend: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
// Caption text field - Beautiful transparent style
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(22.dp))
.background(Color.White.copy(alpha = 0.15f))
) {
BasicTextField(
value = caption,
onValueChange = onCaptionChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
textStyle = androidx.compose.ui.text.TextStyle(
color = Color.White,
fontSize = 16.sp
),
maxLines = 4,
decorationBox = { innerTextField ->
Box {
if (caption.isEmpty()) {
Text(
"Add a caption...",
color = Color.White.copy(alpha = 0.6f),
fontSize = 16.sp
)
}
innerTextField()
}
}
)
}
// Send button - Blue circle like Telegram
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(PrimaryBlue)
.clickable(enabled = !isSaving) { onSend() },
contentAlignment = Alignment.Center
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = TablerIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier
.size(22.dp)
.offset(x = 1.dp) // Slight offset for better centering
)
}
}
}
}
/**
* Caption input bar with send button (like Telegram) - OLD VERSION
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -798,3 +851,280 @@ private fun launchCrop(
Log.e(TAG, "Error launching crop", e)
}
}
/**
* Data class for image with caption
*/
data class ImageWithCaption(
val uri: Uri,
var caption: String = ""
)
/**
* Multi-image editor screen with swipe (like Telegram)
* Позволяет свайпать между фотками и добавлять caption к каждой
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun MultiImageEditorScreen(
imageUris: List<Uri>,
onDismiss: () -> Unit,
onSendAll: (List<ImageWithCaption>) -> Unit,
isDarkTheme: Boolean = true
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// State for each image
val imagesWithCaptions = remember {
mutableStateListOf<ImageWithCaption>().apply {
addAll(imageUris.map { ImageWithCaption(it, "") })
}
}
// Pager state
val pagerState = rememberPagerState(
initialPage = 0,
pageCount = { imagesWithCaptions.size }
)
var isSaving by remember { mutableStateOf(false) }
// Current caption (для текущей страницы)
var currentCaption by remember { mutableStateOf("") }
// Sync caption when page changes
LaunchedEffect(pagerState.currentPage) {
currentCaption = imagesWithCaptions.getOrNull(pagerState.currentPage)?.caption ?: ""
}
BackHandler {
onDismiss()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
// Horizontal Pager для свайпа между фото
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
// Отображаем изображение
AsyncImageLoader(
uri = imagesWithCaptions[page].uri,
modifier = Modifier.fillMaxSize()
)
}
}
// Top toolbar - OVERLAY
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Close button
IconButton(onClick = onDismiss) {
Icon(
TablerIcons.X,
contentDescription = "Close",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
// Page indicator
if (imagesWithCaptions.size > 1) {
Box(
modifier = Modifier
.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
)
}
}
// Spacer for balance
Spacer(modifier = Modifier.size(48.dp))
}
// Bottom section - Caption + Send
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.imePadding()
.navigationBarsPadding()
) {
// Thumbnails strip (если больше 1 фото)
if (imagesWithCaptions.size > 1) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(imagesWithCaptions.size) { index ->
val isSelected = pagerState.currentPage == index
Box(
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(8.dp))
.border(
width = if (isSelected) 2.dp else 0.dp,
color = if (isSelected) PrimaryBlue else Color.Transparent,
shape = RoundedCornerShape(8.dp)
)
.clickable {
scope.launch {
pagerState.animateScrollToPage(index)
}
}
) {
AsyncImageLoader(
uri = imagesWithCaptions[index].uri,
modifier = Modifier.fillMaxSize()
)
}
}
}
}
// Caption input bar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
// Caption text field
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(22.dp))
.background(Color.White.copy(alpha = 0.15f))
) {
BasicTextField(
value = currentCaption,
onValueChange = { newCaption ->
currentCaption = newCaption
// Update caption for current image
if (pagerState.currentPage < imagesWithCaptions.size) {
imagesWithCaptions[pagerState.currentPage] =
imagesWithCaptions[pagerState.currentPage].copy(caption = newCaption)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
textStyle = androidx.compose.ui.text.TextStyle(
color = Color.White,
fontSize = 16.sp
),
maxLines = 4,
decorationBox = { innerTextField ->
Box {
if (currentCaption.isEmpty()) {
Text(
"Add a caption...",
color = Color.White.copy(alpha = 0.6f),
fontSize = 16.sp
)
}
innerTextField()
}
}
)
}
// Send button
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(PrimaryBlue)
.clickable(enabled = !isSaving) {
isSaving = true
onSendAll(imagesWithCaptions.toList())
},
contentAlignment = Alignment.Center
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = TablerIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier
.size(22.dp)
.offset(x = 1.dp)
)
}
}
}
}
}
}
/**
* Simple async image loader composable
*/
@Composable
private fun AsyncImageLoader(
uri: Uri,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var bitmap by remember(uri) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(uri) {
withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(uri)
bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
} catch (e: Exception) {
Log.e(TAG, "Error loading image", e)
}
}
}
bitmap?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = null,
modifier = modifier,
contentScale = androidx.compose.ui.layout.ContentScale.Fit
)
} ?: Box(
modifier = modifier.background(Color.DarkGray),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = Color.White
)
}
}

View File

@@ -32,11 +32,13 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@@ -644,20 +646,44 @@ private fun CollapsingProfileHeader(
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
// Header heights
val expandedHeight = EXPANDED_HEADER_HEIGHT + statusBarHeight
// По умолчанию header = ширина экрана минус отступ для Account, при скролле уменьшается
val expandedHeight = screenWidthDp - 60.dp // Высота header (меньше чтобы не перекрывать Account)
val collapsedHeight = COLLAPSED_HEADER_HEIGHT + statusBarHeight
// Animated header height
val headerHeight = androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress)
// ═══════════════════════════════════════════════════════════
// 👤 AVATAR - shrinks and moves UP until disappears
// 👤 AVATAR - По умолчанию квадратный во весь экран, при скролле становится круглым
// ═══════════════════════════════════════════════════════════
// Размер: ширина = screenWidthDp (полная ширина), высота = expandedHeight
// При скролле уменьшается до 0
val avatarWidth = androidx.compose.ui.unit.lerp(screenWidthDp, 0.dp, collapseProgress)
val avatarHeight = androidx.compose.ui.unit.lerp(expandedHeight, 0.dp, collapseProgress)
// Для cornerRadius и других расчётов используем меньшую сторону
val avatarSize = minOf(avatarWidth, avatarHeight)
// Позиция X: от 0 (весь экран) до центра
val avatarCenterX = (screenWidthDp - AVATAR_SIZE_EXPANDED) / 2
val avatarStartY = statusBarHeight + 32.dp
val avatarEndY = statusBarHeight - 60.dp // Moves above screen
val avatarY = androidx.compose.ui.unit.lerp(avatarStartY, avatarEndY, collapseProgress)
val avatarSize = androidx.compose.ui.unit.lerp(AVATAR_SIZE_EXPANDED, 0.dp, collapseProgress)
val avatarX = androidx.compose.ui.unit.lerp(0.dp, avatarCenterX + (AVATAR_SIZE_EXPANDED - avatarSize) / 2, collapseProgress.coerceIn(0f, 0.5f) * 2f)
// Позиция Y: от 0 (верх экрана) до обычной позиции, потом уходит вверх
val avatarStartY = 0.dp // Начинаем с самого верха (закрываем status bar)
val avatarMidY = statusBarHeight + 32.dp // Средняя позиция (круглый аватар)
val avatarEndY = statusBarHeight - 60.dp // Уходит вверх за экран
val avatarY = if (collapseProgress < 0.5f) {
// Первая половина: от 0 до обычной позиции
androidx.compose.ui.unit.lerp(avatarStartY, avatarMidY, collapseProgress * 2f)
} else {
// Вторая половина: уходит вверх
androidx.compose.ui.unit.lerp(avatarMidY, avatarEndY, (collapseProgress - 0.5f) * 2f)
}
// Закругление: от 0 (квадрат) до половины размера (круг)
val cornerRadius = androidx.compose.ui.unit.lerp(0.dp, avatarSize / 2, collapseProgress.coerceIn(0f, 0.3f) / 0.3f)
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseProgress)
// ═══════════════════════════════════════════════════════════
@@ -679,101 +705,53 @@ private fun CollapsingProfileHeader(
.height(headerHeight)
) {
// ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND (вместо цвета)
// 🎨 BLURRED AVATAR BACKGROUND - только когда аватар уже круглый
// При квадратном аватаре фон не нужен (аватар сам занимает весь header)
// ═══════════════════════════════════════════════════════════
if (collapseProgress > 0.3f) {
BlurredAvatarBackground(
publicKey = publicKey,
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 25f,
alpha = 0.3f
)
// ═══════════════════════════════════════════════════════════
// 🔙 BACK BUTTON
// ═══════════════════════════════════════════════════════════
Box(
modifier = Modifier
.padding(top = statusBarHeight)
.padding(start = 4.dp, top = 4.dp)
.size(48.dp),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = onBack,
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = TablerIcons.ArrowLeft,
contentDescription = "Back",
tint = if (isColorLight(avatarColors.backgroundColor)) Color.Black else Color.White,
modifier = Modifier.size(24.dp)
)
}
}
// ═══════════════════════════════════════════════════════════
// ⋮ MENU BUTTON (top right corner)
// ═══════════════════════════════════════════════════════════
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = statusBarHeight)
.padding(end = 4.dp, top = 4.dp)
.size(48.dp),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = { onAvatarMenuChange(true) },
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = TablerIcons.DotsVertical,
contentDescription = "Profile menu",
tint = if (isColorLight(avatarColors.backgroundColor)) Color.Black else Color.White,
modifier = Modifier.size(24.dp)
)
}
// Меню для установки фото профиля
com.rosetta.messenger.ui.chats.components.ProfilePhotoMenu(
expanded = showAvatarMenu,
onDismiss = { onAvatarMenuChange(false) },
isDarkTheme = isDarkTheme,
onSetPhotoClick = {
onAvatarMenuChange(false)
onSetPhotoClick()
}
alpha = 0.3f * ((collapseProgress - 0.3f) / 0.7f).coerceIn(0f, 1f) // Плавное появление
)
}
// ═══════════════════════════════════════════════════════════
// 👤 AVATAR - shrinks and moves up (with real avatar support)
// <EFBFBD> AVATAR - По умолчанию квадратный, при скролле становится круглым
// РИСУЕМ ПЕРВЫМ чтобы кнопки были поверх
// ═══════════════════════════════════════════════════════════
if (avatarSize > 1.dp) {
Box(
modifier = Modifier
.offset(
x = avatarCenterX + (AVATAR_SIZE_EXPANDED - avatarSize) / 2,
x = avatarX,
y = avatarY
)
.size(avatarSize)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.15f))
.padding(2.dp)
.clip(CircleShape),
.width(avatarWidth)
.height(avatarHeight)
.clip(RoundedCornerShape(cornerRadius)),
contentAlignment = Alignment.Center
) {
// Используем AvatarImage если репозиторий доступен
if (avatarRepository != null) {
// При collapseProgress < 0.2 - fullscreen аватар
if (collapseProgress < 0.2f) {
FullSizeAvatar(
publicKey = publicKey,
avatarRepository = avatarRepository
)
} else {
AvatarImage(
publicKey = publicKey,
avatarRepository = avatarRepository,
size = avatarSize - 4.dp,
isDarkTheme = false, // Header всегда светлый на цветном фоне
isDarkTheme = false,
onClick = null,
showOnlineIndicator = false
)
}
} else {
// Fallback: цветной placeholder с инициалами
Box(
@@ -795,6 +773,64 @@ private fun CollapsingProfileHeader(
}
}
// ═══════════════════════════════════════════════════════════
// 🔙 BACK BUTTON (поверх аватара)
// ═══════════════════════════════════════════════════════════
Box(
modifier = Modifier
.padding(top = statusBarHeight)
.padding(start = 4.dp, top = 4.dp)
.size(48.dp),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = onBack,
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = TablerIcons.ArrowLeft,
contentDescription = "Back",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
// ═══════════════════════════════════════════════════════════
// ⋮ MENU BUTTON (top right corner, поверх аватара)
// ═══════════════════════════════════════════════════════════
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = statusBarHeight)
.padding(end = 4.dp, top = 4.dp)
.size(48.dp),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = { onAvatarMenuChange(true) },
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = TablerIcons.DotsVertical,
contentDescription = "Profile menu",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
// Меню для установки фото профиля
com.rosetta.messenger.ui.chats.components.ProfilePhotoMenu(
expanded = showAvatarMenu,
onDismiss = { onAvatarMenuChange(false) },
isDarkTheme = isDarkTheme,
onSetPhotoClick = {
onAvatarMenuChange(false)
onSetPhotoClick()
}
)
}
// ═══════════════════════════════════════════════════════════
// 📝 TEXT BLOCK - Name + Online, always centered
// ═══════════════════════════════════════════════════════════
@@ -855,7 +891,55 @@ private fun CollapsingProfileHeader(
}
// ═════════════════════════════════════════════════════════════
// 📦 PROFILE CARD COMPONENT - Legacy (kept for OtherProfileScreen)
// <EFBFBD> FULL SIZE AVATAR - Fills entire container (for expanded state)
// ═════════════════════════════════════════════════════════════
@Composable
private fun FullSizeAvatar(
publicKey: String,
avatarRepository: AvatarRepository?
) {
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
var bitmap by remember(avatars) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(avatars) {
bitmap = if (avatars.isNotEmpty()) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
com.rosetta.messenger.utils.AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
}
} else {
null
}
}
if (bitmap != null) {
Image(
bitmap = bitmap!!.asImageBitmap(),
contentDescription = "Avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
val avatarColors = getAvatarColor(publicKey, false)
Box(
modifier = Modifier
.fillMaxSize()
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
Text(
text = publicKey.take(2).uppercase(),
color = avatarColors.textColor,
fontSize = 80.sp,
fontWeight = FontWeight.Bold
)
}
}
}
// ═════════════════════════════════════════════════════════════
// <20>📦 PROFILE CARD COMPONENT - Legacy (kept for OtherProfileScreen)
// ═════════════════════════════════════════════════════════════
@Composable
fun ProfileCard(