From 7691926ef62521bfebc7db4a09721ce6e4f82719 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 30 Jan 2026 02:59:43 +0500 Subject: [PATCH] feat: Implement multi-image editing with captions in ChatDetailScreen and enhance ProfileScreen with overscroll effects --- .../messenger/ui/chats/ChatDetailScreen.kt | 83 ++- .../chats/components/AttachmentComponents.kt | 16 +- .../chats/components/ChatDetailComponents.kt | 11 +- .../ui/chats/components/ImageEditorScreen.kt | 540 ++++++++++++++---- .../messenger/ui/settings/ProfileScreen.kt | 256 ++++++--- 5 files changed, 681 insertions(+), 225 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index ea730fe..6a50cb4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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(null) } // Фото для редактирования - // 📷 Camera launcher + // � Состояние для multi-image editor (галерея) + var pendingGalleryImages by remember { mutableStateOf>(emptyList()) } + + // �📷 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() - - 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)) - } - } - } - - // Отправляем группой (коллаж) - if (imageDataList.isNotEmpty()) { - viewModel.sendImageGroup(imageDataList) - } + // 📸 Открываем edit screen для выбранных изображений + android.util.Log.d("ChatDetailScreen", "📸 Opening editor for ${selectedMedia.size} media items") + + // Собираем URI изображений (пока без видео) + val imageUris = selectedMedia + .filter { !it.isVideo } + .map { it.uri } + + 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() + + 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 + ) + } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index d1732a7..e49dd0e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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 -> { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index bad6c1b..89cfb05 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index d827b88..97c7665 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -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,113 +144,79 @@ fun ImageEditorScreen( .fillMaxSize() .background(Color.Black) ) { - Column( + // Photo Editor View - FULLSCREEN edge-to-edge + AndroidView( + factory = { ctx -> + PhotoEditorView(ctx).apply { + photoEditorView = this + // Load image - fullscreen, CENTER_CROP чтобы заполнить экран + source.setImageURI(currentImageUri) + source.scaleType = ImageView.ScaleType.CENTER_CROP + + // Build PhotoEditor + photoEditor = PhotoEditor.Builder(ctx, this) + .setPinchTextScalable(true) + .setClipSourceImage(true) + .build() + } + }, + update = { view -> + // Apply rotation and flip transformations + view.source.rotation = rotationAngle + view.source.scaleX = if (isFlippedHorizontally) -1f else 1f + view.source.scaleY = if (isFlippedVertically) -1f else 1f + }, modifier = Modifier.fillMaxSize() + ) + + // Top toolbar - OVERLAY (поверх фото) + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - // 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( - factory = { ctx -> - PhotoEditorView(ctx).apply { - photoEditorView = this - // Load image - fullscreen - source.setImageURI(currentImageUri) - source.scaleType = ImageView.ScaleType.FIT_CENTER - - // Build PhotoEditor - photoEditor = PhotoEditor.Builder(ctx, this) - .setPinchTextScalable(true) - .setClipSourceImage(true) - .build() - } - }, - update = { view -> - // Apply rotation and flip transformations - view.source.rotation = rotationAngle - view.source.scaleX = if (isFlippedHorizontally) -1f else 1f - view.source.scaleY = if (isFlippedVertically) -1f else 1f - }, - modifier = Modifier.fillMaxSize() + // 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, + onDismiss: () -> Unit, + onSendAll: (List) -> Unit, + isDarkTheme: Boolean = true +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // State for each image + val imagesWithCaptions = remember { + mutableStateListOf().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(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 + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 1a86d34..9ccaee2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -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) // ═══════════════════════════════════════════════════════════ - 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() - } + if (collapseProgress > 0.3f) { + BlurredAvatarBackground( + publicKey = publicKey, + avatarRepository = avatarRepository, + fallbackColor = avatarColors.backgroundColor, + blurRadius = 25f, + alpha = 0.3f * ((collapseProgress - 0.3f) / 0.7f).coerceIn(0f, 1f) // Плавное появление ) } // ═══════════════════════════════════════════════════════════ - // 👤 AVATAR - shrinks and moves up (with real avatar support) + // � 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) { - AvatarImage( - publicKey = publicKey, - avatarRepository = avatarRepository, - size = avatarSize - 4.dp, - isDarkTheme = false, // Header всегда светлый на цветном фоне - onClick = null, - showOnlineIndicator = false - ) + // При 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, + 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) +// � 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(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 + ) + } + } +} + +// ═════════════════════════════════════════════════════════════ +// �📦 PROFILE CARD COMPONENT - Legacy (kept for OtherProfileScreen) // ═════════════════════════════════════════════════════════════ @Composable fun ProfileCard(