feat: Implement multi-image editing with captions in ChatDetailScreen and enhance ProfileScreen with overscroll effects
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user