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.ui.chats.ForwardChatPickerBottomSheet
import com.rosetta.messenger.utils.MediaUtils import com.rosetta.messenger.utils.MediaUtils
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen 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 app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -188,7 +190,10 @@ fun ChatDetailScreen(
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка // 📷 Состояние для flow камеры: фото → редактор с caption → отправка
var pendingCameraPhotoUri by remember { mutableStateOf<Uri?>(null) } // Фото для редактирования 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( val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture() contract = ActivityResultContracts.TakePicture()
) { success -> ) { success ->
@@ -1988,31 +1993,16 @@ fun ChatDetailScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey, currentUserPublicKey = currentUserPublicKey,
onMediaSelected = { selectedMedia -> onMediaSelected = { selectedMedia ->
// 📸 Отправляем выбранные изображения как коллаж (группу) // 📸 Открываем edit screen для выбранных изображений
android.util.Log.d("ChatDetailScreen", "📸 Sending ${selectedMedia.size} media items as group") android.util.Log.d("ChatDetailScreen", "📸 Opening editor for ${selectedMedia.size} media items")
scope.launch {
// Собираем все изображения
val imageDataList = mutableListOf<ChatViewModel.ImageData>()
for (item in selectedMedia) { // Собираем URI изображений (пока без видео)
if (item.isVideo) { val imageUris = selectedMedia
// TODO: Поддержка видео .filter { !it.isVideo }
android.util.Log.d("ChatDetailScreen", "📹 Video not supported yet: ${item.uri}") .map { it.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))
}
}
}
// Отправляем группой (коллаж) if (imageUris.isNotEmpty()) {
if (imageDataList.isNotEmpty()) { pendingGalleryImages = imageUris
viewModel.sendImageGroup(imageDataList)
}
} }
}, },
onOpenCamera = { onOpenCamera = {
@@ -2078,4 +2068,45 @@ fun ChatDetailScreen(
showCaptionInput = true 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, messageStatus: MessageStatus = MessageStatus.READ,
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото
onImageClick: (attachmentId: String) -> Unit = {}, onImageClick: (attachmentId: String) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -106,6 +107,7 @@ fun MessageAttachments(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
hasCaption = hasCaption,
onImageClick = onImageClick onImageClick = onImageClick
) )
} }
@@ -163,18 +165,26 @@ fun ImageCollage(
isDarkTheme: Boolean, isDarkTheme: Boolean,
timestamp: java.util.Date, timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ, messageStatus: MessageStatus = MessageStatus.READ,
hasCaption: Boolean = false, // Если есть caption - время показывается под фото
onImageClick: (attachmentId: String) -> Unit = {}, onImageClick: (attachmentId: String) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val count = attachments.size val count = attachments.size
val spacing = 2.dp val spacing = 2.dp
// Показываем время и статус только на последнем изображении коллажа // Показываем время и статус только если нет caption
val showOverlayOnLast = true 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( Box(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(12.dp)) .clip(collageShape)
) { ) {
when (count) { when (count) {
1 -> { 1 -> {

View File

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

View File

@@ -16,12 +16,17 @@ import androidx.core.content.FileProvider
import com.yalantis.ucrop.UCrop import com.yalantis.ucrop.UCrop
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -30,7 +35,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -137,95 +144,14 @@ fun ImageEditorScreen(
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
) { ) {
Column( // Photo Editor View - FULLSCREEN edge-to-edge
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
) {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
PhotoEditorView(ctx).apply { PhotoEditorView(ctx).apply {
photoEditorView = this photoEditorView = this
// Load image - fullscreen // Load image - fullscreen, CENTER_CROP чтобы заполнить экран
source.setImageURI(currentImageUri) source.setImageURI(currentImageUri)
source.scaleType = ImageView.ScaleType.FIT_CENTER source.scaleType = ImageView.ScaleType.CENTER_CROP
// Build PhotoEditor // Build PhotoEditor
photoEditor = PhotoEditor.Builder(ctx, this) photoEditor = PhotoEditor.Builder(ctx, this)
@@ -242,8 +168,55 @@ fun ImageEditorScreen(
}, },
modifier = Modifier.fillMaxSize() 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) // Color picker bar (when drawing)
AnimatedVisibility( AnimatedVisibility(
visible = currentTool == EditorTool.DRAW && showColorPicker, 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) { if (showCaptionInput) {
CaptionInputBar( TelegramCaptionInputBar(
caption = caption, caption = caption,
onCaptionChange = { caption = it }, onCaptionChange = { caption = it },
isSaving = isSaving, 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -798,3 +851,280 @@ private fun launchCrop(
Log.e(TAG, "Error launching crop", e) 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.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -644,20 +646,44 @@ private fun CollapsingProfileHeader(
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
// Header heights // Header heights
val expandedHeight = EXPANDED_HEADER_HEIGHT + statusBarHeight // По умолчанию header = ширина экрана минус отступ для Account, при скролле уменьшается
val expandedHeight = screenWidthDp - 60.dp // Высота header (меньше чтобы не перекрывать Account)
val collapsedHeight = COLLAPSED_HEADER_HEIGHT + statusBarHeight val collapsedHeight = COLLAPSED_HEADER_HEIGHT + statusBarHeight
// Animated header height // Animated header height
val headerHeight = androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress) 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 avatarCenterX = (screenWidthDp - AVATAR_SIZE_EXPANDED) / 2
val avatarStartY = statusBarHeight + 32.dp val avatarX = androidx.compose.ui.unit.lerp(0.dp, avatarCenterX + (AVATAR_SIZE_EXPANDED - avatarSize) / 2, collapseProgress.coerceIn(0f, 0.5f) * 2f)
val avatarEndY = statusBarHeight - 60.dp // Moves above screen
val avatarY = androidx.compose.ui.unit.lerp(avatarStartY, avatarEndY, collapseProgress) // Позиция Y: от 0 (верх экрана) до обычной позиции, потом уходит вверх
val avatarSize = androidx.compose.ui.unit.lerp(AVATAR_SIZE_EXPANDED, 0.dp, collapseProgress) 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) val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseProgress)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -679,101 +705,53 @@ private fun CollapsingProfileHeader(
.height(headerHeight) .height(headerHeight)
) { ) {
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND (вместо цвета) // 🎨 BLURRED AVATAR BACKGROUND - только когда аватар уже круглый
// При квадратном аватаре фон не нужен (аватар сам занимает весь header)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
if (collapseProgress > 0.3f) {
BlurredAvatarBackground( BlurredAvatarBackground(
publicKey = publicKey, publicKey = publicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor, fallbackColor = avatarColors.backgroundColor,
blurRadius = 25f, blurRadius = 25f,
alpha = 0.3f alpha = 0.3f * ((collapseProgress - 0.3f) / 0.7f).coerceIn(0f, 1f) // Плавное появление
)
// ═══════════════════════════════════════════════════════════
// 🔙 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()
}
) )
} }
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 👤 AVATAR - shrinks and moves up (with real avatar support) // <EFBFBD> AVATAR - По умолчанию квадратный, при скролле становится круглым
// РИСУЕМ ПЕРВЫМ чтобы кнопки были поверх
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
if (avatarSize > 1.dp) { if (avatarSize > 1.dp) {
Box( Box(
modifier = Modifier modifier = Modifier
.offset( .offset(
x = avatarCenterX + (AVATAR_SIZE_EXPANDED - avatarSize) / 2, x = avatarX,
y = avatarY y = avatarY
) )
.size(avatarSize) .width(avatarWidth)
.clip(CircleShape) .height(avatarHeight)
.background(Color.White.copy(alpha = 0.15f)) .clip(RoundedCornerShape(cornerRadius)),
.padding(2.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Используем AvatarImage если репозиторий доступен // Используем AvatarImage если репозиторий доступен
if (avatarRepository != null) { if (avatarRepository != null) {
// При collapseProgress < 0.2 - fullscreen аватар
if (collapseProgress < 0.2f) {
FullSizeAvatar(
publicKey = publicKey,
avatarRepository = avatarRepository
)
} else {
AvatarImage( AvatarImage(
publicKey = publicKey, publicKey = publicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
size = avatarSize - 4.dp, size = avatarSize - 4.dp,
isDarkTheme = false, // Header всегда светлый на цветном фоне isDarkTheme = false,
onClick = null, onClick = null,
showOnlineIndicator = false showOnlineIndicator = false
) )
}
} else { } else {
// Fallback: цветной placeholder с инициалами // Fallback: цветной placeholder с инициалами
Box( 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 // 📝 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 @Composable
fun ProfileCard( fun ProfileCard(