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