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 ad32dd7..705f5ba 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 @@ -180,9 +180,10 @@ fun ChatDetailScreen( // 📨 Forward: показывать ли выбор чата var showForwardPicker by remember { mutableStateOf(false) } - // � Image Viewer state + // 📸 Image Viewer state with Telegram-style shared element animation var showImageViewer by remember { mutableStateOf(false) } var imageViewerInitialIndex by remember { mutableStateOf(0) } + var imageViewerSourceBounds by remember { mutableStateOf(null) } // 📷 Camera: URI для сохранения фото var cameraImageUri by remember { mutableStateOf(null) } @@ -1763,15 +1764,16 @@ fun ChatDetailScreen( message.id ) }, - onImageClick = { attachmentId -> - // 📸 Открыть просмотрщик фото + onImageClick = { attachmentId, bounds -> + // 📸 Открыть просмотрщик фото с shared element animation val allImages = extractImagesFromMessages( - messages, + messages, currentUserPublicKey, user.publicKey, user.title.ifEmpty { "User" } ) imageViewerInitialIndex = findImageIndex(allImages, attachmentId) + imageViewerSourceBounds = bounds showImageViewer = true } ) @@ -1784,10 +1786,10 @@ fun ChatDetailScreen( } } // Закрытие Box - // 📸 Image Viewer Overlay + // 📸 Image Viewer Overlay with Telegram-style shared element animation if (showImageViewer) { val allImages = extractImagesFromMessages( - messages, + messages, currentUserPublicKey, user.publicKey, user.title.ifEmpty { "User" } @@ -1796,8 +1798,12 @@ fun ChatDetailScreen( images = allImages, initialIndex = imageViewerInitialIndex, privateKey = currentUserPrivateKey, - onDismiss = { showImageViewer = false }, - isDarkTheme = isDarkTheme + onDismiss = { + showImageViewer = false + imageViewerSourceBounds = null + }, + isDarkTheme = isDarkTheme, + sourceBounds = imageViewerSourceBounds ) } @@ -2055,8 +2061,8 @@ fun ChatDetailScreen( pendingCameraPhotoUri?.let { uri -> ImageEditorScreen( imageUri = uri, - onDismiss = { - pendingCameraPhotoUri = null + onDismiss = { + pendingCameraPhotoUri = null }, onSave = { editedUri -> // Fallback если onSaveWithCaption не сработал @@ -2082,7 +2088,8 @@ fun ChatDetailScreen( } }, isDarkTheme = isDarkTheme, - showCaptionInput = true + showCaptionInput = true, + recipientName = user.title ) } @@ -2123,7 +2130,8 @@ fun ChatDetailScreen( } } }, - isDarkTheme = isDarkTheme + isDarkTheme = isDarkTheme, + recipientName = user.title ) } } 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 df74f6b..4764e6f 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 @@ -3,7 +3,9 @@ package com.rosetta.messenger.ui.chats.components import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Base64 +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -18,10 +20,14 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -72,7 +78,7 @@ fun MessageAttachments( avatarRepository: AvatarRepository? = null, currentUserPublicKey: String = "", hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото - onImageClick: (attachmentId: String) -> Unit = {}, + onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, modifier: Modifier = Modifier ) { if (attachments.isEmpty()) return @@ -156,7 +162,7 @@ fun ImageCollage( timestamp: java.util.Date, messageStatus: MessageStatus = MessageStatus.READ, hasCaption: Boolean = false, // Если есть caption - время показывается под фото - onImageClick: (attachmentId: String) -> Unit = {}, + onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, modifier: Modifier = Modifier ) { val count = attachments.size @@ -458,11 +464,14 @@ fun ImageAttachment( showTimeOverlay: Boolean = true, aspectRatio: Float? = null, fillMaxSize: Boolean = false, - onImageClick: (attachmentId: String) -> Unit = {} + onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> } ) { val context = LocalContext.current val scope = rememberCoroutineScope() + // Bounds для shared element transition + var imageBounds by remember { mutableStateOf(null) } + var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var imageBitmap by remember { mutableStateOf(null) } var blurhashBitmap by remember { mutableStateOf(null) } @@ -686,17 +695,33 @@ fun ImageAttachment( else -> Modifier.size(220.dp) } + val cornerRadius = if (fillMaxSize) 0f else 12f + Box( modifier = sizeModifier - .clip(RoundedCornerShape(if (fillMaxSize) 0.dp else 12.dp)) + .clip(RoundedCornerShape(cornerRadius.dp)) .background(Color.Transparent) + .onGloballyPositioned { coordinates -> + // Capture bounds for shared element transition + imageBounds = coordinates.boundsInWindow() + } .clickable { when (downloadStatus) { DownloadStatus.NOT_DOWNLOADED -> download() DownloadStatus.DOWNLOADED -> { - // 📸 Open image viewer - onImageClick(attachment.id) + // 📸 Open image viewer with bounds for animation + val bounds = imageBounds?.let { + ImageSourceBounds( + left = it.left, + top = it.top, + width = it.width, + height = it.height, + cornerRadius = cornerRadius, + thumbnailBitmap = imageBitmap + ) + } + onImageClick(attachment.id, bounds) } DownloadStatus.ERROR -> download() else -> {} @@ -898,6 +923,18 @@ fun FileAttachment( var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var downloadProgress by remember { mutableStateOf(0f) } + // Bounce animation for icon + val iconScale = remember { Animatable(0f) } + LaunchedEffect(Unit) { + iconScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = 0.5f, // Bouncy effect + stiffness = 400f + ) + ) + } + val preview = attachment.preview val downloadTag = getDownloadTag(preview) val (fileSize, fileName) = parseFilePreview(preview) @@ -970,7 +1007,15 @@ fun FileAttachment( verticalAlignment = Alignment.CenterVertically ) { // File icon с индикатором прогресса - круглая иконка как в desktop - Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(40.dp) + .graphicsLayer { + scaleX = iconScale.value + scaleY = iconScale.value + }, + contentAlignment = Alignment.Center + ) { // Круглый фон иконки Box( modifier = @@ -1153,6 +1198,18 @@ fun AvatarAttachment( val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) } + // Bounce animation for avatar + val avatarScale = remember { Animatable(0f) } + LaunchedEffect(Unit) { + avatarScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = 0.5f, // Bouncy effect + stiffness = 400f + ) + ) + } + // Определяем начальный статус (как в Desktop calcDownloadStatus для AVATAR) LaunchedEffect(attachment.id) { withContext(Dispatchers.IO) { @@ -1315,6 +1372,10 @@ fun AvatarAttachment( Box( modifier = Modifier.size(56.dp) + .graphicsLayer { + scaleX = avatarScale.value + scaleY = avatarScale.value + } .clip(CircleShape) .background( if (isOutgoing) Color.White.copy(0.15f) 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 ec02b49..1ef917b 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 @@ -231,7 +231,7 @@ fun MessageBubble( onReplyClick: (String) -> Unit = {}, onRetry: () -> Unit = {}, onDelete: () -> Unit = {}, - onImageClick: (attachmentId: String) -> Unit = {} + onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> } ) { // Swipe-to-reply state var swipeOffset by remember { mutableStateOf(0f) } 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 dd2528b..5313d2b 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 @@ -11,6 +11,7 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.* +import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image @@ -30,11 +31,14 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -55,38 +59,51 @@ import kotlinx.coroutines.withContext private const val TAG = "ImageEditorScreen" +/** Telegram-style easing */ +private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) + /** Available editing tools */ enum class EditorTool { NONE, DRAW, CROP, - ROTATE + ROTATE, + TEXT } -/** Drawing colors */ -val drawingColors = - listOf( - Color.White, - Color.Black, - Color.Red, - Color(0xFFFF9500), // Orange - Color.Yellow, - Color(0xFF34C759), // Green - Color(0xFF007AFF), // Blue - Color(0xFF5856D6), // Purple - Color(0xFFFF2D55), // Pink - ) +/** Drawing colors - Telegram style palette */ +val drawingColors = listOf( + Color.White, + Color.Black, + Color(0xFFFF3B30), // Red + Color(0xFFFF9500), // Orange + Color(0xFFFFCC00), // Yellow + Color(0xFF34C759), // Green + Color(0xFF007AFF), // Blue + Color(0xFF5856D6), // Purple + Color(0xFFFF2D55), // Pink +) -/** Telegram-style image editor screen with caption input */ +/** + * 📸 Telegram-style Image Editor Screen + * + * Features: + * - Fullscreen edge-to-edge photo display + * - Transparent overlay controls + * - Smooth animations + * - Drawing, Crop, Rotate tools + * - Caption input with send button + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ImageEditorScreen( - imageUri: Uri, - onDismiss: () -> Unit, - onSave: (Uri) -> Unit, - onSaveWithCaption: ((Uri, String) -> Unit)? = null, // New callback with caption - isDarkTheme: Boolean = true, - showCaptionInput: Boolean = false // Show caption input for camera flow + imageUri: Uri, + onDismiss: () -> Unit, + onSave: (Uri) -> Unit, + onSaveWithCaption: ((Uri, String) -> Unit)? = null, + isDarkTheme: Boolean = true, + showCaptionInput: Boolean = false, + recipientName: String? = null // Имя получателя (как в Telegram) ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -94,9 +111,8 @@ fun ImageEditorScreen( // Editor state var currentTool by remember { mutableStateOf(EditorTool.NONE) } var selectedColor by remember { mutableStateOf(Color.White) } - var brushSize by remember { mutableStateOf(10f) } + var brushSize by remember { mutableStateOf(12f) } var showColorPicker by remember { mutableStateOf(false) } - var showBrushSizeSlider by remember { mutableStateOf(false) } var isSaving by remember { mutableStateOf(false) } // Caption state @@ -114,360 +130,389 @@ fun ImageEditorScreen( var photoEditor by remember { mutableStateOf(null) } var photoEditorView by remember { mutableStateOf(null) } + // Animation for enter + val enterAnimation = remember { Animatable(0f) } + LaunchedEffect(Unit) { + enterAnimation.animateTo( + targetValue = 1f, + animationSpec = tween(250, easing = TelegramEasing) + ) + } + // UCrop launcher - val cropLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - result.data?.let { data -> - UCrop.getOutput(data)?.let { croppedUri -> - currentImageUri = croppedUri - // Reload image in PhotoEditorView - photoEditorView?.source?.setImageURI(croppedUri) - } - } + val cropLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.let { data -> + UCrop.getOutput(data)?.let { croppedUri -> + currentImageUri = croppedUri + photoEditorView?.source?.setImageURI(croppedUri) } } + } + } BackHandler { onDismiss() } - Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { - // Photo Editor View - FULLSCREEN edge-to-edge + // Telegram behavior: photo stays fullscreen, only input moves with keyboard + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .graphicsLayer { + alpha = enterAnimation.value + scaleX = 0.95f + 0.05f * enterAnimation.value + scaleY = 0.95f + 0.05f * enterAnimation.value + } + ) { + // ═══════════════════════════════════════════════════════════ + // 📸 FULLSCREEN PHOTO - занимает ВЕСЬ экран, не реагирует на клавиатуру + // ═══════════════════════════════════════════════════════════ AndroidView( - factory = { ctx -> - PhotoEditorView(ctx).apply { - photoEditorView = this - // Load image - fullscreen, CENTER_CROP чтобы заполнить экран - source.setImageURI(currentImageUri) - source.scaleType = ImageView.ScaleType.CENTER_CROP + factory = { ctx -> + PhotoEditorView(ctx).apply { + photoEditorView = this - // Build PhotoEditor - photoEditor = - PhotoEditor.Builder(ctx, this) - .setPinchTextScalable(true) - .setClipSourceImage(true) - .build() + // Убираем отступы + setPadding(0, 0, 0, 0) + setBackgroundColor(android.graphics.Color.BLACK) + + // Простой FIT_CENTER - показывает ВСЁ фото, центрирует + source.apply { + setImageURI(currentImageUri) + scaleType = ImageView.ScaleType.FIT_CENTER + adjustViewBounds = true + setPadding(0, 0, 0, 0) } - }, - 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() + + photoEditor = PhotoEditor.Builder(ctx, this) + .setPinchTextScalable(true) + .setClipSourceImage(true) + .build() + } + }, + update = { view -> + view.source.rotation = rotationAngle + view.source.scaleX = if (isFlippedHorizontally) -1f else 1f + view.source.scaleY = if (isFlippedVertically) -1f else 1f + }, + // КРИТИЧНО: fillMaxSize без imePadding - фото НЕ сжимается при клавиатуре + 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 BAR - Transparent overlay (Telegram style) + // ═══════════════════════════════════════════════════════════ + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.5f), + Color.Transparent + ) + ) + ) + .statusBarsPadding() + .padding(horizontal = 4.dp, vertical = 8.dp) ) { - // Close button - IconButton(onClick = onDismiss) { + // Close button (X) + IconButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.CenterStart) + ) { Icon( - TablerIcons.X, - contentDescription = "Close", - tint = Color.White, - modifier = Modifier.size(28.dp) + TablerIcons.X, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(28.dp) ) } - // Undo / Redo buttons - Row { + // Recipient name (center) - как в Telegram + if (recipientName != null) { + Text( + text = recipientName, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 56.dp) // Отступы от кнопок + ) + } + + // Undo / Redo buttons (center-right) + Row( + modifier = Modifier.align(Alignment.CenterEnd), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { IconButton(onClick = { photoEditor?.undo() }) { Icon( - TablerIcons.ArrowBackUp, - contentDescription = "Undo", - tint = Color.White, - modifier = Modifier.size(26.dp) + 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) + 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() + // ═══════════════════════════════════════════════════════════ + // 🎨 BOTTOM SECTION - TELEGRAM STYLE: всё overlay поверх фото! + // Фото идёт ПОД toolbar и caption (они прозрачные) + // ═══════════════════════════════════════════════════════════ + + // Color picker (overlay) + AnimatedVisibility( + visible = currentTool == EditorTool.DRAW && showColorPicker, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 140.dp) // над caption и toolbar ) { - // Color picker bar (when drawing) - AnimatedVisibility( - visible = currentTool == EditorTool.DRAW && showColorPicker, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - ColorPickerBar( - selectedColor = selectedColor, - onColorSelected = { color -> - selectedColor = color - photoEditor?.brushColor = color.toArgb() + TelegramColorPicker( + selectedColor = selectedColor, + brushSize = brushSize, + onColorSelected = { color -> + selectedColor = color + photoEditor?.brushColor = color.toArgb() + }, + onBrushSizeChanged = { size -> + brushSize = size + photoEditor?.brushSize = size + } + ) + } + + // Rotate options (overlay) + AnimatedVisibility( + visible = currentTool == EditorTool.ROTATE, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 140.dp) + ) { + TelegramRotateBar( + onRotateLeft = { rotationAngle = (rotationAngle - 90f) % 360f }, + onRotateRight = { rotationAngle = (rotationAngle + 90f) % 360f }, + onFlipHorizontal = { isFlippedHorizontally = !isFlippedHorizontally }, + onFlipVertical = { isFlippedVertically = !isFlippedVertically } + ) + } + + // ═══════════════════════════════════════════════════════════ + // 📝 CAPTION INPUT - отдельно, поднимается с клавиатурой (как в Telegram) + // Прячется с анимацией когда открыты инструменты (рисовалка, поворот и т.д.) + // ═══════════════════════════════════════════════════════════ + + // Определяем видимость клавиатуры + val isKeyboardVisibleForCaption = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + // Когда клавиатура закрыта - добавляем отступ снизу для toolbar (~100dp) + val bottomPaddingForCaption = if (!isKeyboardVisibleForCaption) 100.dp else 0.dp + + AnimatedVisibility( + visible = showCaptionInput && currentTool == EditorTool.NONE, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = bottomPaddingForCaption) + .imePadding() // поднимается с клавиатурой + ) { + TelegramCaptionBar( + caption = caption, + onCaptionChange = { caption = it }, + isSaving = isSaving, + onSend = { + scope.launch { + isSaving = true + saveEditedImage(context, photoEditor, photoEditorView, currentImageUri) { savedUri -> + isSaving = false + if (savedUri != null) { + if (onSaveWithCaption != null) { + onSaveWithCaption(savedUri, caption) + } else { + onSave(savedUri) + } + } } - ) - } + } + } + ) + } - // Brush size slider - AnimatedVisibility( - visible = currentTool == EditorTool.DRAW && showBrushSizeSlider, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + // ═══════════════════════════════════════════════════════════ + // 🛠️ TOOLBAR - показывается только когда клавиатура ЗАКРЫТА + // ═══════════════════════════════════════════════════════════ + val isKeyboardOpen = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + + AnimatedVisibility( + visible = !isKeyboardOpen, + enter = fadeIn() + slideInVertically { it }, + exit = fadeOut() + slideOutVertically { it }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.6f) + ) + ) + ) ) { - BrushSizeBar( - brushSize = brushSize, - onBrushSizeChanged = { size -> - brushSize = size - photoEditor?.brushSize = size - }, - selectedColor = selectedColor - ) - } - - // Rotate/Flip options bar - AnimatedVisibility( - visible = currentTool == EditorTool.ROTATE, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - RotateOptionsBar( - onRotateLeft = { rotationAngle = (rotationAngle - 90f) % 360f }, - onRotateRight = { rotationAngle = (rotationAngle + 90f) % 360f }, - onFlipHorizontal = { isFlippedHorizontally = !isFlippedHorizontally }, - onFlipVertical = { isFlippedVertically = !isFlippedVertically } - ) - } - - // Caption input bar (Telegram-style) - Beautiful overlay - if (showCaptionInput) { - TelegramCaptionInputBar( - caption = caption, - onCaptionChange = { caption = it }, - isSaving = isSaving, - onSend = { + // Toolbar + TelegramToolbar( + currentTool = currentTool, + showCaptionInput = showCaptionInput, + isSaving = isSaving, + onCropClick = { + currentTool = EditorTool.NONE + showColorPicker = false + photoEditor?.setBrushDrawingMode(false) + launchCrop(context, currentImageUri, cropLauncher) + }, + onRotateClick = { + currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE + showColorPicker = false + photoEditor?.setBrushDrawingMode(false) + }, + onDrawClick = { + if (currentTool == EditorTool.DRAW) { + showColorPicker = !showColorPicker + } else { + currentTool = EditorTool.DRAW + photoEditor?.setBrushDrawingMode(true) + photoEditor?.brushColor = selectedColor.toArgb() + photoEditor?.brushSize = brushSize + showColorPicker = true + } + }, + onEraserClick = { + photoEditor?.brushEraser() + }, + onDoneClick = { + if (!showCaptionInput) { scope.launch { isSaving = true - saveEditedImage(context, photoEditor) { savedUri -> + saveEditedImage(context, photoEditor, photoEditorView, currentImageUri) { savedUri -> isSaving = false if (savedUri != null) { - if (onSaveWithCaption != null) { - onSaveWithCaption(savedUri, caption) - } else { - onSave(savedUri) - } + onSave(savedUri) } } } } - ) - } - - // Bottom toolbar with tools - BottomToolbar( - currentTool = currentTool, - onToolSelected = { tool -> - when (tool) { - EditorTool.DRAW -> { - if (currentTool == EditorTool.DRAW) { - showColorPicker = !showColorPicker - showBrushSizeSlider = false - } else { - currentTool = tool - photoEditor?.setBrushDrawingMode(true) - photoEditor?.brushColor = selectedColor.toArgb() - photoEditor?.brushSize = brushSize - showColorPicker = true - } - } - EditorTool.CROP -> { - currentTool = EditorTool.NONE - showColorPicker = false - showBrushSizeSlider = false - photoEditor?.setBrushDrawingMode(false) - // Launch UCrop - launchCrop(context, currentImageUri, cropLauncher) - } - EditorTool.ROTATE -> { - currentTool = - if (currentTool == EditorTool.ROTATE) EditorTool.NONE - else tool - showColorPicker = false - showBrushSizeSlider = false - photoEditor?.setBrushDrawingMode(false) - } - else -> { - currentTool = EditorTool.NONE - showColorPicker = false - showBrushSizeSlider = false - photoEditor?.setBrushDrawingMode(false) - } - } - }, - onBrushSizeClick = { - showBrushSizeSlider = !showBrushSizeSlider - showColorPicker = false - }, - onEraserClick = { photoEditor?.brushEraser() }, - onCropClick = { launchCrop(context, currentImageUri, cropLauncher) }, - onRotateClick = { - currentTool = - if (currentTool == EditorTool.ROTATE) EditorTool.NONE - else EditorTool.ROTATE - showColorPicker = false - showBrushSizeSlider = false - photoEditor?.setBrushDrawingMode(false) } - ) + ) + + Spacer(modifier = Modifier.navigationBarsPadding()) + } } } } -/** Telegram-style Caption input bar with send button - Beautiful transparent overlay */ -@OptIn(ExperimentalMaterial3Api::class) +/** + * Telegram-style toolbar - icons only, no labels + */ @Composable -private fun TelegramCaptionInputBar( - caption: String, - onCaptionChange: (String) -> Unit, - isSaving: Boolean, - onSend: () -> Unit +private fun TelegramToolbar( + currentTool: EditorTool, + showCaptionInput: Boolean, + isSaving: Boolean, + onCropClick: () -> Unit, + onRotateClick: () -> Unit, + onDrawClick: () -> Unit, + onEraserClick: () -> Unit, + onDoneClick: () -> Unit ) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { - // Caption text field - Beautiful transparent style - Box( - modifier = - Modifier.weight(1f) - .clip(RoundedCornerShape(22.dp)) - .background(Color.White.copy(alpha = 0.15f)) + // Crop + TelegramToolButton( + icon = TablerIcons.Crop, + isSelected = currentTool == EditorTool.CROP, + onClick = onCropClick + ) + + // Rotate + TelegramToolButton( + icon = TablerIcons.Rotate, + isSelected = currentTool == EditorTool.ROTATE, + onClick = onRotateClick + ) + + // Draw + TelegramToolButton( + icon = TablerIcons.Pencil, + isSelected = currentTool == EditorTool.DRAW, + onClick = onDrawClick + ) + + // Eraser (visible when drawing) + AnimatedVisibility( + visible = currentTool == EditorTool.DRAW, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut() ) { - 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() - } - } + TelegramToolButton( + icon = TablerIcons.Eraser, + isSelected = false, + onClick = onEraserClick ) } - // Send button - Blue circle like Telegram - Box( - modifier = - Modifier.size(48.dp).clip(CircleShape).background(PrimaryBlue).clickable( - enabled = !isSaving - ) { onSend() }, + // Done/Check button (if no caption input) + if (!showCaptionInput) { + Spacer(modifier = Modifier.weight(1f)) + + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(PrimaryBlue) + .clickable(enabled = !isSaving) { onDoneClick() }, 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 -private fun CaptionInputBar( - caption: String, - onCaptionChange: (String) -> Unit, - isSaving: Boolean, - onSend: () -> Unit -) { - Surface(color = Color(0xFF1C1C1E), modifier = Modifier.fillMaxWidth()) { - Row( - modifier = - Modifier.fillMaxWidth() - .imePadding() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Caption text field - OutlinedTextField( - value = caption, - onValueChange = onCaptionChange, - modifier = Modifier.weight(1f), - placeholder = { - Text("Add a caption...", color = Color.White.copy(alpha = 0.5f)) - }, - maxLines = 3, - colors = - TextFieldDefaults.outlinedTextFieldColors( - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, - cursorColor = PrimaryBlue, - focusedBorderColor = PrimaryBlue, - unfocusedBorderColor = Color.White.copy(alpha = 0.3f) - ), - shape = RoundedCornerShape(20.dp) - ) - - // Send button - FloatingActionButton( - onClick = onSend, - containerColor = PrimaryBlue, - modifier = Modifier.size(48.dp), - elevation = FloatingActionButtonDefaults.elevation(0.dp) ) { if (isSaving) { CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = Color.White, - strokeWidth = 2.dp + 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) + TablerIcons.Check, + contentDescription = "Done", + tint = Color.White, + modifier = Modifier.size(24.dp) ) } } @@ -475,353 +520,470 @@ private fun CaptionInputBar( } } +/** + * Single tool button - Telegram style (no label) + */ @Composable -private fun BottomToolbar( - currentTool: EditorTool, - onToolSelected: (EditorTool) -> Unit, - onBrushSizeClick: () -> Unit, - onEraserClick: () -> Unit, - onCropClick: () -> Unit, - onRotateClick: () -> Unit +private fun TelegramToolButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + isSelected: Boolean, + onClick: () -> Unit ) { - Surface(color = Color(0xFF1C1C1E), modifier = Modifier.fillMaxWidth()) { - Row( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - .navigationBarsPadding(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - // Crop tool - ToolButton( - icon = TablerIcons.Crop, - label = "Crop", - isSelected = currentTool == EditorTool.CROP, - onClick = onCropClick - ) - - // Rotate tool - ToolButton( - icon = TablerIcons.Rotate, - label = "Rotate", - isSelected = currentTool == EditorTool.ROTATE, - onClick = onRotateClick - ) - - // Draw tool - ToolButton( - icon = TablerIcons.Pencil, - label = "Draw", - isSelected = currentTool == EditorTool.DRAW, - onClick = { onToolSelected(EditorTool.DRAW) } - ) - - // Eraser (when drawing) - AnimatedVisibility( - visible = currentTool == EditorTool.DRAW, - enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut() - ) { - ToolButton( - icon = TablerIcons.Eraser, - label = "Eraser", - isSelected = false, - onClick = onEraserClick - ) - } - - // Brush size (when drawing) - AnimatedVisibility( - visible = currentTool == EditorTool.DRAW, - enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut() - ) { - ToolButton( - icon = TablerIcons.Circle, - label = "Size", - isSelected = false, - onClick = onBrushSizeClick - ) - } - } - } -} - -@Composable -private fun ToolButton( - icon: androidx.compose.ui.graphics.vector.ImageVector, - label: String, - isSelected: Boolean, - onClick: () -> Unit -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier.clip(RoundedCornerShape(8.dp)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onClick - ) - .padding(8.dp) + IconButton( + onClick = onClick, + modifier = Modifier.size(48.dp) ) { Icon( - imageVector = icon, - contentDescription = label, - tint = if (isSelected) PrimaryBlue else Color.White, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = label, - color = if (isSelected) PrimaryBlue else Color.White.copy(alpha = 0.7f), - fontSize = 10.sp + imageVector = icon, + contentDescription = null, + tint = if (isSelected) PrimaryBlue else Color.White, + modifier = Modifier.size(26.dp) ) } } +/** + * Telegram-style color picker with brush size + */ @Composable -private fun ColorPickerBar(selectedColor: Color, onColorSelected: (Color) -> Unit) { - Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) { +private fun TelegramColorPicker( + selectedColor: Color, + brushSize: Float, + onColorSelected: (Color) -> Unit, + onBrushSizeChanged: (Float) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + // Brush size slider + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(selectedColor) + ) + + Slider( + value = brushSize, + onValueChange = onBrushSizeChanged, + valueRange = 5f..40f, + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp), + colors = SliderDefaults.colors( + thumbColor = selectedColor, + activeTrackColor = selectedColor, + inactiveTrackColor = Color.White.copy(alpha = 0.3f) + ) + ) + + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(selectedColor) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Colors row LazyRow( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 8.dp) + horizontalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(horizontal = 4.dp) ) { items(drawingColors) { color -> - ColorButton( - color = color, - isSelected = color == selectedColor, - onClick = { onColorSelected(color) } + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(color) + .then( + if (color == selectedColor) { + Modifier.border(3.dp, Color.White, CircleShape) + } else { + Modifier.border(1.dp, Color.White.copy(alpha = 0.3f), CircleShape) + } + ) + .clickable { onColorSelected(color) }, + contentAlignment = Alignment.Center + ) { + if (color == selectedColor) { + Icon( + TablerIcons.Check, + contentDescription = null, + tint = if (color == Color.White) Color.Black else Color.White, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + } +} + +/** + * Telegram-style rotate bar + */ +@Composable +private fun TelegramRotateBar( + onRotateLeft: () -> Unit, + onRotateRight: () -> Unit, + onFlipHorizontal: () -> Unit, + onFlipVertical: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + TelegramToolButton( + icon = TablerIcons.RotateClockwise2, + isSelected = false, + onClick = onRotateLeft + ) + + TelegramToolButton( + icon = TablerIcons.Rotate2, + isSelected = false, + onClick = onRotateRight + ) + + TelegramToolButton( + icon = TablerIcons.FlipHorizontal, + isSelected = false, + onClick = onFlipHorizontal + ) + + TelegramToolButton( + icon = TablerIcons.FlipVertical, + isSelected = false, + onClick = onFlipVertical + ) + } +} + +/** + * Telegram-style caption input bar + */ +@Composable +private fun TelegramCaptionBar( + caption: String, + onCaptionChange: (String) -> Unit, + isSaving: Boolean, + onSend: () -> Unit +) { + // Telegram-style: прямоугольный темный бар с emoji слева и галочкой справа + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.75f)) + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Emoji icon (как в Telegram) + Icon( + TablerIcons.MoodSmile, + contentDescription = "Emoji", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(26.dp) + ) + + // Caption text field - простой без фона + BasicTextField( + value = caption, + onValueChange = onCaptionChange, + modifier = Modifier.weight(1f), + 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.5f), + fontSize = 16.sp + ) + } + innerTextField() + } + } + ) + + // Send button - голубой кружок с галочкой (как в Telegram) + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(PrimaryBlue) + .clickable(enabled = !isSaving) { onSend() }, + contentAlignment = Alignment.Center + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Icon( + TablerIcons.Check, + contentDescription = "Send", + tint = Color.White, + modifier = Modifier.size(20.dp) ) } } } } -@Composable -private fun ColorButton(color: Color, isSelected: Boolean, onClick: () -> Unit) { - Box( - modifier = - Modifier.size(32.dp) - .clip(CircleShape) - .background(color) - .then( - if (isSelected) { - Modifier.border(3.dp, Color.White, CircleShape) - } else { - Modifier.border( - 1.dp, - Color.White.copy(alpha = 0.3f), - CircleShape - ) - } - ) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center - ) { - if (isSelected) { - Icon( - Icons.Default.Check, - contentDescription = null, - tint = if (color == Color.White) Color.Black else Color.White, - modifier = Modifier.size(16.dp) - ) - } - } -} - -@Composable -private fun BrushSizeBar( - brushSize: Float, - onBrushSizeChanged: (Float) -> Unit, - selectedColor: Color -) { - Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Min indicator - Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(selectedColor)) - - // Slider - Slider( - value = brushSize, - onValueChange = onBrushSizeChanged, - valueRange = 5f..50f, - modifier = Modifier.weight(1f).padding(horizontal = 16.dp), - colors = - SliderDefaults.colors( - thumbColor = selectedColor, - activeTrackColor = selectedColor - ) - ) - - // Max indicator - Box(modifier = Modifier.size(24.dp).clip(CircleShape).background(selectedColor)) - } - } -} - -/** Save edited image and return the URI */ +/** Save edited image and return the URI - crops black bars from FIT_CENTER */ private suspend fun saveEditedImage( - context: Context, - photoEditor: PhotoEditor?, - onResult: (Uri?) -> Unit + context: Context, + photoEditor: PhotoEditor?, + photoEditorView: PhotoEditorView?, + imageUri: Uri, + onResult: (Uri?) -> Unit ) { - if (photoEditor == null) { + if (photoEditor == null || photoEditorView == null) { onResult(null) return } withContext(Dispatchers.IO) { try { - val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png") + val tempFile = File(context.cacheDir, "temp_${System.currentTimeMillis()}.png") - val saveSettings = - SaveSettings.Builder() - .setClearViewsEnabled(false) - .setTransparencyEnabled(true) - .build() + val saveSettings = SaveSettings.Builder() + .setClearViewsEnabled(false) + .setTransparencyEnabled(true) + .build() - photoEditor.saveAsFile( - file.absolutePath, + // Сохраняем полный view (с черными полосами) + val savedPath = suspendCancellableCoroutine { continuation -> + photoEditor.saveAsFile( + tempFile.absolutePath, saveSettings, object : PhotoEditor.OnSaveListener { override fun onSuccess(imagePath: String) { - onResult(Uri.fromFile(File(imagePath))) + continuation.resume(imagePath) } - override fun onFailure(exception: Exception) { - onResult(null) + continuation.resume(null) } } + ) + } + + if (savedPath == null) { + withContext(Dispatchers.Main) { onResult(null) } + return@withContext + } + + // Загружаем сохраненное изображение + val savedBitmap = BitmapFactory.decodeFile(savedPath) + if (savedBitmap == null) { + withContext(Dispatchers.Main) { onResult(Uri.fromFile(tempFile)) } + return@withContext + } + + // Получаем РЕАЛЬНЫЕ размеры изображения из URI (не из drawable!) + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(imageUri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, options) + } + + val imageWidth = options.outWidth + val imageHeight = options.outHeight + + if (imageWidth <= 0 || imageHeight <= 0) { + withContext(Dispatchers.Main) { onResult(Uri.fromFile(tempFile)) } + return@withContext + } + + val viewWidth = savedBitmap.width + val viewHeight = savedBitmap.height + + // Вычисляем где находится изображение (FIT_CENTER логика) + val scale = minOf( + viewWidth.toFloat() / imageWidth, + viewHeight.toFloat() / imageHeight ) + + val scaledWidth = (imageWidth * scale).toInt() + val scaledHeight = (imageHeight * scale).toInt() + + val left = ((viewWidth - scaledWidth) / 2).coerceAtLeast(0) + val top = ((viewHeight - scaledHeight) / 2).coerceAtLeast(0) + + // Обрезаем черные полосы + val croppedBitmap = Bitmap.createBitmap( + savedBitmap, + left, + top, + scaledWidth.coerceAtMost(viewWidth - left), + scaledHeight.coerceAtMost(viewHeight - top) + ) + + // Сохраняем обрезанное изображение + val finalFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png") + java.io.FileOutputStream(finalFile).use { out -> + croppedBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + + savedBitmap.recycle() + croppedBitmap.recycle() + tempFile.delete() + + withContext(Dispatchers.Main) { + onResult(Uri.fromFile(finalFile)) + } } catch (e: Exception) { withContext(Dispatchers.Main) { onResult(null) } } } } -/** Save edited image synchronously using suspendCoroutine */ -private suspend fun saveEditedImageSync(context: Context, photoEditor: PhotoEditor?): Uri? { - if (photoEditor == null) return null +/** Save edited image synchronously - crops black bars from FIT_CENTER */ +private suspend fun saveEditedImageSync( + context: Context, + photoEditor: PhotoEditor?, + photoEditorView: PhotoEditorView?, + imageUri: Uri +): Uri? { + if (photoEditor == null || photoEditorView == null) return null return withContext(Dispatchers.IO) { try { - val file = - File( - context.cacheDir, - "edited_${System.currentTimeMillis()}_${(0..9999).random()}.png" - ) + val tempFile = File( + context.cacheDir, + "temp_${System.currentTimeMillis()}_${(0..9999).random()}.png" + ) - val saveSettings = - SaveSettings.Builder() - .setClearViewsEnabled(false) - .setTransparencyEnabled(true) - .build() + val saveSettings = SaveSettings.Builder() + .setClearViewsEnabled(false) + .setTransparencyEnabled(true) + .build() - suspendCancellableCoroutine { continuation -> + val savedPath = suspendCancellableCoroutine { continuation -> photoEditor.saveAsFile( - file.absolutePath, - saveSettings, - object : PhotoEditor.OnSaveListener { - override fun onSuccess(imagePath: String) { - continuation.resume(Uri.fromFile(File(imagePath))) - } - - override fun onFailure(exception: Exception) { - continuation.resume(null) - } + tempFile.absolutePath, + saveSettings, + object : PhotoEditor.OnSaveListener { + override fun onSuccess(imagePath: String) { + continuation.resume(imagePath) } + override fun onFailure(exception: Exception) { + continuation.resume(null) + } + } ) } + + if (savedPath == null) return@withContext null + + // Загружаем сохраненное изображение + val savedBitmap = BitmapFactory.decodeFile(savedPath) + ?: return@withContext Uri.fromFile(tempFile) + + // Получаем РЕАЛЬНЫЕ размеры изображения из URI (не из drawable!) + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(imageUri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, options) + } + + val imageWidth = options.outWidth + val imageHeight = options.outHeight + + if (imageWidth <= 0 || imageHeight <= 0) { + return@withContext Uri.fromFile(tempFile) + } + + val viewWidth = savedBitmap.width + val viewHeight = savedBitmap.height + + // Вычисляем где находится изображение (FIT_CENTER логика) + val scale = minOf( + viewWidth.toFloat() / imageWidth, + viewHeight.toFloat() / imageHeight + ) + + val scaledWidth = (imageWidth * scale).toInt() + val scaledHeight = (imageHeight * scale).toInt() + + val left = ((viewWidth - scaledWidth) / 2).coerceAtLeast(0) + val top = ((viewHeight - scaledHeight) / 2).coerceAtLeast(0) + + // Обрезаем черные полосы + val croppedBitmap = Bitmap.createBitmap( + savedBitmap, + left, + top, + scaledWidth.coerceAtMost(viewWidth - left), + scaledHeight.coerceAtMost(viewHeight - top) + ) + + // Сохраняем обрезанное изображение + val finalFile = File( + context.cacheDir, + "edited_${System.currentTimeMillis()}_${(0..9999).random()}.png" + ) + java.io.FileOutputStream(finalFile).use { out -> + croppedBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + + savedBitmap.recycle() + croppedBitmap.recycle() + tempFile.delete() + + Uri.fromFile(finalFile) } catch (e: Exception) { null } } } -/** Rotate/Flip options bar */ -@Composable -private fun RotateOptionsBar( - onRotateLeft: () -> Unit, - onRotateRight: () -> Unit, - onFlipHorizontal: () -> Unit, - onFlipVertical: () -> Unit -) { - Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - // Rotate left - ToolButton( - icon = TablerIcons.RotateClockwise2, - label = "Left", - isSelected = false, - onClick = onRotateLeft - ) - - // Rotate right - ToolButton( - icon = TablerIcons.Rotate2, - label = "Right", - isSelected = false, - onClick = onRotateRight - ) - - // Flip horizontal - ToolButton( - icon = TablerIcons.FlipHorizontal, - label = "Flip H", - isSelected = false, - onClick = onFlipHorizontal - ) - - // Flip vertical - ToolButton( - icon = TablerIcons.FlipVertical, - label = "Flip V", - isSelected = false, - onClick = onFlipVertical - ) - } - } -} - -/** Launch UCrop activity for image cropping */ +/** Launch UCrop activity */ private fun launchCrop( - context: Context, - sourceUri: Uri, - launcher: androidx.activity.result.ActivityResultLauncher + context: Context, + sourceUri: Uri, + launcher: androidx.activity.result.ActivityResultLauncher ) { try { val destinationFile = File(context.cacheDir, "cropped_${System.currentTimeMillis()}.png") val destinationUri = Uri.fromFile(destinationFile) - val options = - UCrop.Options().apply { - setCompressionFormat(Bitmap.CompressFormat.PNG) - setCompressionQuality(100) - setToolbarColor(android.graphics.Color.parseColor("#1C1C1E")) - setStatusBarColor(android.graphics.Color.parseColor("#1C1C1E")) - setActiveControlsWidgetColor(android.graphics.Color.parseColor("#007AFF")) - setToolbarWidgetColor(android.graphics.Color.WHITE) - setRootViewBackgroundColor(android.graphics.Color.BLACK) - setFreeStyleCropEnabled(true) - setShowCropGrid(true) - setShowCropFrame(true) - setHideBottomControls(false) - } + val options = UCrop.Options().apply { + setCompressionFormat(Bitmap.CompressFormat.PNG) + setCompressionQuality(100) + // Dark theme + setToolbarColor(android.graphics.Color.BLACK) + setStatusBarColor(android.graphics.Color.BLACK) + setActiveControlsWidgetColor(android.graphics.Color.parseColor("#007AFF")) + setToolbarWidgetColor(android.graphics.Color.WHITE) + setRootViewBackgroundColor(android.graphics.Color.BLACK) + setFreeStyleCropEnabled(true) + setShowCropGrid(true) + setShowCropFrame(true) + setHideBottomControls(false) + } val intent = UCrop.of(sourceUri, destinationUri).withOptions(options).getIntent(context) - launcher.launch(intent) } catch (e: Exception) { + // Handle error } } @@ -829,74 +991,57 @@ private fun launchCrop( data class ImageWithCaption(val uri: Uri, var caption: String = "") /** - * Multi-image editor screen with swipe (like Telegram) Позволяет свайпать между фотками и добавлять - * caption к каждой + * 📸 Multi-image editor with swipe (Telegram style) */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun MultiImageEditorScreen( - imageUris: List, - onDismiss: () -> Unit, - onSendAll: (List) -> Unit, - isDarkTheme: Boolean = true + imageUris: List, + onDismiss: () -> Unit, + onSendAll: (List) -> Unit, + isDarkTheme: Boolean = true, + recipientName: String? = null // Имя получателя (как в Telegram) ) { 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("") } - - // === EDITING STATE === var currentTool by remember { mutableStateOf(EditorTool.NONE) } var selectedColor by remember { mutableStateOf(Color.White) } - var brushSize by remember { mutableFloatStateOf(15f) } + var brushSize by remember { mutableFloatStateOf(12f) } var showColorPicker by remember { mutableStateOf(false) } - var showBrushSizeSlider by remember { mutableStateOf(false) } - // PhotoEditor references for each page val photoEditors = remember { mutableStateMapOf() } val photoEditorViews = remember { mutableStateMapOf() } - // Crop launcher - val cropLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - result.data?.let { data -> - UCrop.getOutput(data)?.let { croppedUri -> - val currentPage = pagerState.currentPage - if (currentPage < imagesWithCaptions.size) { - imagesWithCaptions[currentPage] = - imagesWithCaptions[currentPage].copy(uri = croppedUri) - } - } + val cropLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.let { data -> + UCrop.getOutput(data)?.let { croppedUri -> + val currentPage = pagerState.currentPage + if (currentPage < imagesWithCaptions.size) { + imagesWithCaptions[currentPage] = imagesWithCaptions[currentPage].copy(uri = croppedUri) } } } - - // Sync caption when page changes - LaunchedEffect(pagerState.currentPage) { - currentCaption = imagesWithCaptions.getOrNull(pagerState.currentPage)?.caption ?: "" - // Reset editing tools when changing page - currentTool = EditorTool.NONE - showColorPicker = false - showBrushSizeSlider = false + } + } + + LaunchedEffect(pagerState.currentPage) { + currentTool = EditorTool.NONE + showColorPicker = false } - // Update brush settings when they change LaunchedEffect(selectedColor, brushSize) { val currentPage = pagerState.currentPage photoEditors[currentPage]?.let { editor -> @@ -909,372 +1054,331 @@ fun MultiImageEditorScreen( BackHandler { onDismiss() } - Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { - // Horizontal Pager для свайпа между фото + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + // Pager HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - userScrollEnabled = currentTool == EditorTool.NONE // Disable swipe when editing + state = pagerState, + modifier = Modifier.fillMaxSize(), + userScrollEnabled = currentTool == EditorTool.NONE ) { page -> - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - // PhotoEditorView for editing + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { AndroidView( - factory = { ctx -> - PhotoEditorView(ctx).apply { - photoEditorViews[page] = this + factory = { ctx -> + PhotoEditorView(ctx).apply { + photoEditorViews[page] = this - // Load bitmap - scope.launch(Dispatchers.IO) { - try { - val inputStream = - ctx.contentResolver.openInputStream( - imagesWithCaptions[page].uri - ) - val bitmap = BitmapFactory.decodeStream(inputStream) - inputStream?.close() + setPadding(0, 0, 0, 0) + setBackgroundColor(android.graphics.Color.BLACK) - withContext(Dispatchers.Main) { - source.setImageBitmap(bitmap) + // Загружаем изображение + scope.launch(Dispatchers.IO) { + try { + val inputStream = ctx.contentResolver.openInputStream(imagesWithCaptions[page].uri) + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() - // Create PhotoEditor - val editor = - PhotoEditor.Builder(ctx, this@apply) - .setPinchTextScalable(true) - .build() - photoEditors[page] = editor + withContext(Dispatchers.Main) { + source.apply { + setImageBitmap(bitmap) + scaleType = ImageView.ScaleType.FIT_CENTER + adjustViewBounds = true + setPadding(0, 0, 0, 0) } - } catch (e: Exception) { - } - } - } - }, - modifier = Modifier.fillMaxSize(), - update = { view -> - // Reload if URI changed (after crop) - val currentUri = imagesWithCaptions.getOrNull(page)?.uri - if (currentUri != null) { - scope.launch(Dispatchers.IO) { - try { - val inputStream = - context.contentResolver.openInputStream(currentUri) - val bitmap = BitmapFactory.decodeStream(inputStream) - inputStream?.close() - withContext(Dispatchers.Main) { - view.source.setImageBitmap(bitmap) - } - } catch (e: Exception) { + val editor = PhotoEditor.Builder(ctx, this@apply) + .setPinchTextScalable(true) + .build() + photoEditors[page] = editor } + } catch (e: Exception) { + // Handle error } } } + }, + modifier = Modifier.fillMaxSize(), + update = { view -> + val currentUri = imagesWithCaptions.getOrNull(page)?.uri + if (currentUri != null) { + scope.launch(Dispatchers.IO) { + try { + val inputStream = context.contentResolver.openInputStream(currentUri) + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + withContext(Dispatchers.Main) { + view.source.setImageBitmap(bitmap) + view.source.scaleType = ImageView.ScaleType.FIT_CENTER + } + } catch (e: Exception) { + // Handle error + } + } + } + } ) } } - // Top toolbar - OVERLAY - Row( - modifier = - Modifier.fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + // Top bar + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.5f), + Color.Transparent + ) + ) + ) + .statusBarsPadding() + .padding(horizontal = 4.dp, vertical = 8.dp) ) { - // Close button - IconButton(onClick = onDismiss) { + IconButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.CenterStart) + ) { Icon( - TablerIcons.X, - contentDescription = "Close", - tint = Color.White, - modifier = Modifier.size(28.dp) + TablerIcons.X, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + + // Recipient name (center) - как в Telegram + if (recipientName != null) { + Text( + text = recipientName, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 56.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) + modifier = Modifier + .align(Alignment.Center) + .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 + text = "${pagerState.currentPage + 1} / ${imagesWithCaptions.size}", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium ) } } - // Undo button (when drawing) if (currentTool == EditorTool.DRAW) { - IconButton(onClick = { photoEditors[pagerState.currentPage]?.undo() }) { + IconButton( + onClick = { photoEditors[pagerState.currentPage]?.undo() }, + modifier = Modifier.align(Alignment.CenterEnd) + ) { Icon( - TablerIcons.ArrowBackUp, - contentDescription = "Undo", - tint = Color.White, - modifier = Modifier.size(28.dp) + TablerIcons.ArrowBackUp, + contentDescription = "Undo", + tint = Color.White, + modifier = Modifier.size(26.dp) ) } - } else { - // Spacer for balance - Spacer(modifier = Modifier.size(48.dp)) } } - // Bottom section - Tools + Caption + Send - Column(modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).imePadding()) { - // Color picker bar (when drawing) + // Bottom section - без imePadding, фото не сжимается + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.7f) + ) + ) + ) + ) { + // Color picker AnimatedVisibility( - visible = showColorPicker && currentTool == EditorTool.DRAW, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + visible = showColorPicker && currentTool == EditorTool.DRAW, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { - ColorPickerBar( - selectedColor = selectedColor, - onColorSelected = { color -> - selectedColor = color - photoEditors[pagerState.currentPage]?.brushColor = color.toArgb() - } + TelegramColorPicker( + selectedColor = selectedColor, + brushSize = brushSize, + onColorSelected = { color -> + selectedColor = color + photoEditors[pagerState.currentPage]?.brushColor = color.toArgb() + }, + onBrushSizeChanged = { size -> + brushSize = size + photoEditors[pagerState.currentPage]?.brushSize = size + } ) } - // Brush size slider (when drawing) - AnimatedVisibility( - visible = showBrushSizeSlider && currentTool == EditorTool.DRAW, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - BrushSizeBar( - brushSize = brushSize, - onBrushSizeChanged = { size -> - brushSize = size - photoEditors[pagerState.currentPage]?.brushSize = size - }, - selectedColor = selectedColor - ) - } - - // Rotate options bar - AnimatedVisibility( - visible = currentTool == EditorTool.ROTATE, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - RotateOptionsBar( - onRotateLeft = { - photoEditorViews[pagerState.currentPage]?.source?.rotation = - (photoEditorViews[pagerState.currentPage]?.source?.rotation - ?: 0f) - 90f - }, - onRotateRight = { - photoEditorViews[pagerState.currentPage]?.source?.rotation = - (photoEditorViews[pagerState.currentPage]?.source?.rotation - ?: 0f) + 90f - }, - onFlipHorizontal = { - photoEditorViews[pagerState.currentPage]?.source?.let { imageView -> - imageView.scaleX = -imageView.scaleX - } - }, - onFlipVertical = { - photoEditorViews[pagerState.currentPage]?.source?.let { imageView -> - imageView.scaleY = -imageView.scaleY - } - } - ) - } - - // Thumbnails strip (если больше 1 фото) + // Thumbnails strip if (imagesWithCaptions.size > 1 && currentTool == EditorTool.NONE) { LazyRow( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + 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) - } - } + 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() + uri = imagesWithCaptions[index].uri, + modifier = Modifier.fillMaxSize() ) } } } } - // Send button (without caption input for multi-image) - AnimatedVisibility( - visible = currentTool == EditorTool.NONE, - enter = fadeIn(), - exit = fadeOut() + // Toolbar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End - ) { - // Send button - Box( - modifier = - Modifier.size(48.dp) - .clip(CircleShape) - .background(PrimaryBlue) - .clickable(enabled = !isSaving) { - isSaving = true - // Save all edited images before sending - scope.launch { - val savedImages = - mutableListOf() + TelegramToolButton( + icon = TablerIcons.Crop, + isSelected = false, + onClick = { + currentTool = EditorTool.NONE + showColorPicker = false + photoEditors[pagerState.currentPage]?.setBrushDrawingMode(false) + launchCrop(context, imagesWithCaptions[pagerState.currentPage].uri, cropLauncher) + } + ) - for (i in imagesWithCaptions.indices) { - val editor = photoEditors[i] - val originalImage = imagesWithCaptions[i] - - if (editor != null) { - // Save edited image - val savedUri = - saveEditedImageSync( - context, - editor - ) - if (savedUri != null) { - savedImages.add( - originalImage.copy( - uri = savedUri - ) - ) - } else { - // Fallback to original if save - // fails - savedImages.add(originalImage) - } - } else { - // No editor for this page, use original - savedImages.add(originalImage) - } - } - - onSendAll(savedImages) - } - }, - contentAlignment = Alignment.Center - ) { - if (isSaving) { - CircularProgressIndicator( - modifier = Modifier.size(22.dp), - color = Color.White, - strokeWidth = 2.dp - ) + TelegramToolButton( + icon = TablerIcons.Pencil, + isSelected = currentTool == EditorTool.DRAW, + onClick = { + val currentEditor = photoEditors[pagerState.currentPage] + if (currentTool == EditorTool.DRAW) { + showColorPicker = !showColorPicker } else { - Icon( - imageVector = TablerIcons.Send, - contentDescription = "Send", - tint = Color.White, - modifier = Modifier.size(22.dp).offset(x = 1.dp) - ) + currentTool = EditorTool.DRAW + currentEditor?.setBrushDrawingMode(true) + currentEditor?.brushColor = selectedColor.toArgb() + currentEditor?.brushSize = brushSize + showColorPicker = true } } + ) + + AnimatedVisibility( + visible = currentTool == EditorTool.DRAW, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut() + ) { + TelegramToolButton( + icon = TablerIcons.Eraser, + isSelected = false, + onClick = { photoEditors[pagerState.currentPage]?.brushEraser() } + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Send button + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(PrimaryBlue) + .clickable(enabled = !isSaving) { + isSaving = true + scope.launch { + val savedImages = mutableListOf() + + for (i in imagesWithCaptions.indices) { + val editor = photoEditors[i] + val editorView = photoEditorViews[i] + val originalImage = imagesWithCaptions[i] + + if (editor != null) { + val savedUri = saveEditedImageSync(context, editor, editorView, originalImage.uri) + if (savedUri != null) { + savedImages.add(originalImage.copy(uri = savedUri)) + } else { + savedImages.add(originalImage) + } + } else { + savedImages.add(originalImage) + } + } + + onSendAll(savedImages) + } + }, + contentAlignment = Alignment.Center + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Icon( + TablerIcons.Send, + contentDescription = "Send", + tint = Color.White, + modifier = Modifier + .size(22.dp) + .offset(x = 1.dp) + ) + } } } - // Bottom toolbar with editing tools - BottomToolbar( - currentTool = currentTool, - onToolSelected = { tool -> - val currentEditor = photoEditors[pagerState.currentPage] - when (tool) { - EditorTool.DRAW -> { - currentTool = - if (currentTool == EditorTool.DRAW) EditorTool.NONE - else tool - if (currentTool == EditorTool.DRAW) { - currentEditor?.setBrushDrawingMode(true) - currentEditor?.brushColor = selectedColor.toArgb() - currentEditor?.brushSize = brushSize - showColorPicker = true - } else { - currentEditor?.setBrushDrawingMode(false) - showColorPicker = false - } - showBrushSizeSlider = false - } - EditorTool.CROP -> { - currentTool = EditorTool.NONE - showColorPicker = false - showBrushSizeSlider = false - currentEditor?.setBrushDrawingMode(false) - // Launch UCrop - launchCrop( - context, - imagesWithCaptions[pagerState.currentPage].uri, - cropLauncher - ) - } - EditorTool.ROTATE -> { - currentTool = - if (currentTool == EditorTool.ROTATE) EditorTool.NONE - else tool - showColorPicker = false - showBrushSizeSlider = false - currentEditor?.setBrushDrawingMode(false) - } - else -> { - currentTool = EditorTool.NONE - showColorPicker = false - showBrushSizeSlider = false - currentEditor?.setBrushDrawingMode(false) - } - } - }, - onBrushSizeClick = { - showBrushSizeSlider = !showBrushSizeSlider - showColorPicker = false - }, - onEraserClick = { photoEditors[pagerState.currentPage]?.brushEraser() }, - onCropClick = { - launchCrop( - context, - imagesWithCaptions[pagerState.currentPage].uri, - cropLauncher - ) - }, - onRotateClick = { - currentTool = - if (currentTool == EditorTool.ROTATE) EditorTool.NONE - else EditorTool.ROTATE - showColorPicker = false - showBrushSizeSlider = false - photoEditors[pagerState.currentPage]?.setBrushDrawingMode(false) - } - ) + Spacer(modifier = Modifier.navigationBarsPadding()) } } } -/** Simple async image loader composable */ +/** Async image loader */ @Composable private fun AsyncImageLoader(uri: Uri, modifier: Modifier = Modifier) { val context = LocalContext.current @@ -1287,20 +1391,26 @@ private fun AsyncImageLoader(uri: Uri, modifier: Modifier = Modifier) { bitmap = BitmapFactory.decodeStream(inputStream) inputStream?.close() } catch (e: Exception) { + // Handle error } } } bitmap?.let { bmp -> Image( - bitmap = bmp.asImageBitmap(), - contentDescription = null, - modifier = modifier, - contentScale = androidx.compose.ui.layout.ContentScale.Fit + bitmap = bmp.asImageBitmap(), + contentDescription = null, + modifier = modifier, + contentScale = ContentScale.Crop + ) + } ?: Box( + modifier = modifier.background(Color.DarkGray), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 2.dp ) } - ?: 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/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index d23dde3..8d14370 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -22,14 +22,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight @@ -57,6 +62,24 @@ import kotlin.math.roundToInt private const val TAG = "ImageViewerScreen" +/** + * Telegram-style CubicBezierInterpolator (0.25, 0.1, 0.25, 1.0) + * Это основной интерполятор используемый в Telegram для открытия фото + */ +private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) + +/** + * Данные об источнике изображения для shared element transition + */ +data class ImageSourceBounds( + val left: Float, + val top: Float, + val width: Float, + val height: Float, + val cornerRadius: Float = 16f, + val thumbnailBitmap: Bitmap? = null +) + /** * Данные для просмотра изображения */ @@ -74,12 +97,13 @@ data class ViewableImage( /** * 📸 Полноэкранный просмотрщик фото в стиле Telegram - * + * * Функции: + * - Shared element transition анимация открытия/закрытия (как в Telegram) * - Свайп влево/вправо для листания фото * - Pinch-to-zoom * - Свайп вниз для закрытия - * - Плавные анимации + * - Плавные анимации 200ms с CubicBezier easing * - Индикатор позиции */ @OptIn(ExperimentalFoundationApi::class) @@ -89,73 +113,167 @@ fun ImageViewerScreen( initialIndex: Int, privateKey: String, onDismiss: () -> Unit, - isDarkTheme: Boolean = true + isDarkTheme: Boolean = true, + sourceBounds: ImageSourceBounds? = null ) { if (images.isEmpty()) { onDismiss() return } - + val scope = rememberCoroutineScope() val context = LocalContext.current val view = LocalView.current val focusManager = LocalFocusManager.current - + val density = LocalDensity.current + + // ═══════════════════════════════════════════════════════════ + // 🎬 TELEGRAM-STYLE SHARED ELEMENT ANIMATION + // ═══════════════════════════════════════════════════════════ + + // Animation state: 0 = animating in, 1 = fully open, 2 = animating out + var animationState by remember { mutableStateOf(if (sourceBounds != null) 0 else 1) } + var isClosing by remember { mutableStateOf(false) } + + // Animation progress (0 = at source, 1 = fullscreen) + val animationProgress = remember { Animatable(if (sourceBounds != null) 0f else 1f) } + + // Screen size + var screenSize by remember { mutableStateOf(IntSize.Zero) } + + // Start enter animation + LaunchedEffect(Unit) { + if (sourceBounds != null && animationState == 0) { + animationProgress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = 200, + easing = TelegramEasing + ) + ) + animationState = 1 + } + } + + // Close with animation + fun closeWithAnimation() { + if (isClosing) return + isClosing = true + + if (sourceBounds != null) { + scope.launch { + animationProgress.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 200, + easing = TelegramEasing + ) + ) + onDismiss() + } + } else { + onDismiss() + } + } + // Закрываем клавиатуру при открытии LaunchedEffect(Unit) { val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager imm.hideSoftInputFromWindow(view.windowToken, 0) focusManager.clearFocus() } - + // Pager state val pagerState = rememberPagerState( initialPage = initialIndex.coerceIn(0, images.size - 1), pageCount = { images.size } ) - + // UI visibility state var showControls by remember { mutableStateOf(true) } - + // Drag to dismiss var offsetY by remember { mutableStateOf(0f) } var isDragging by remember { mutableStateOf(false) } val dismissThreshold = 200f - - // Animated background alpha based on drag - val backgroundAlpha by animateFloatAsState( - targetValue = if (isDragging) { - (1f - (offsetY.absoluteValue / 500f)).coerceIn(0.3f, 1f) - } else 1f, - animationSpec = tween(150), - label = "backgroundAlpha" - ) - + + // Animated background alpha based on animation progress and drag + val baseAlpha = animationProgress.value + val dragAlpha = if (isDragging) { + (1f - (offsetY.absoluteValue / 500f)).coerceIn(0.3f, 1f) + } else 1f + val backgroundAlpha = baseAlpha * dragAlpha + // Current image info val currentImage = images.getOrNull(pagerState.currentPage) val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) } - + BackHandler { - onDismiss() + closeWithAnimation() } - + + // ═══════════════════════════════════════════════════════════ + // 🎬 CALCULATE ANIMATED TRANSFORMS (Telegram style) + // ═══════════════════════════════════════════════════════════ + val progress = animationProgress.value + + // Calculate interpolated values for shared element transition + val animatedScale: Float + val animatedTranslationX: Float + val animatedTranslationY: Float + val animatedCornerRadius: Float + + if (sourceBounds != null && screenSize.width > 0 && screenSize.height > 0) { + // Source state (thumbnail in chat) + val sourceScale = sourceBounds.width / screenSize.width.toFloat() + val sourceCenterX = sourceBounds.left + sourceBounds.width / 2f + val sourceCenterY = sourceBounds.top + sourceBounds.height / 2f + val screenCenterX = screenSize.width / 2f + val screenCenterY = screenSize.height / 2f + + // Interpolate between source and fullscreen + animatedScale = sourceScale + (1f - sourceScale) * progress + animatedTranslationX = (sourceCenterX - screenCenterX) * (1f - progress) + animatedTranslationY = (sourceCenterY - screenCenterY) * (1f - progress) + animatedCornerRadius = sourceBounds.cornerRadius * (1f - progress) + } else { + animatedScale = 1f + animatedTranslationX = 0f + animatedTranslationY = 0f + animatedCornerRadius = 0f + } + Box( modifier = Modifier .fillMaxSize() + .onSizeChanged { screenSize = it } .background(Color.Black.copy(alpha = backgroundAlpha)) ) { // ═══════════════════════════════════════════════════════════ - // 📸 HORIZONTAL PAGER + // 📸 HORIZONTAL PAGER with shared element animation // ═══════════════════════════════════════════════════════════ HorizontalPager( state = pagerState, modifier = Modifier .fillMaxSize() - .offset { IntOffset(0, offsetY.roundToInt()) }, + .graphicsLayer { + // Apply Telegram-style shared element transform + scaleX = animatedScale + scaleY = animatedScale + translationX = animatedTranslationX + translationY = animatedTranslationY + offsetY + // Clip with animated corner radius + clip = animatedCornerRadius > 0f + shape = if (animatedCornerRadius > 0f) { + RoundedCornerShape(animatedCornerRadius.dp) + } else { + RectangleShape + } + }, key = { images[it].attachmentId } ) { page -> val image = images[page] - + ZoomableImage( image = image, privateKey = privateKey, @@ -167,7 +285,7 @@ fun ImageViewerScreen( onDragEnd = { isDragging = false if (offsetY.absoluteValue > dismissThreshold) { - onDismiss() + closeWithAnimation() } else { offsetY = 0f } @@ -176,12 +294,12 @@ fun ImageViewerScreen( } // ═══════════════════════════════════════════════════════════ - // 🎛️ TOP BAR + // 🎛️ TOP BAR (показываем только когда анимация завершена) // ═══════════════════════════════════════════════════════════ AnimatedVisibility( - visible = showControls, - enter = fadeIn() + slideInVertically { -it }, - exit = fadeOut() + slideOutVertically { -it }, + visible = showControls && animationState == 1 && !isClosing, + enter = fadeIn(tween(150)) + slideInVertically { -it }, + exit = fadeOut(tween(100)) + slideOutVertically { -it }, modifier = Modifier.align(Alignment.TopCenter) ) { Box( @@ -195,7 +313,7 @@ fun ImageViewerScreen( ) { // Back button IconButton( - onClick = onDismiss, + onClick = { closeWithAnimation() }, modifier = Modifier.align(Alignment.CenterStart) ) { Icon( @@ -230,13 +348,13 @@ fun ImageViewerScreen( } // ═══════════════════════════════════════════════════════════ - // 📍 PAGE INDICATOR (если больше 1 фото) + // 📍 PAGE INDICATOR (если больше 1 фото, показываем когда анимация завершена) // ═══════════════════════════════════════════════════════════ if (images.size > 1) { AnimatedVisibility( - visible = showControls, - enter = fadeIn(), - exit = fadeOut(), + visible = showControls && animationState == 1 && !isClosing, + enter = fadeIn(tween(150)), + exit = fadeOut(tween(100)), modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 32.dp) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index dcc9337..bdda5b6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -11,6 +11,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.* import androidx.compose.animation.core.* +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.foundation.* import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource @@ -447,7 +448,7 @@ private fun QuickActionsRow( ) { val buttonColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) val iconColor = if (isDarkTheme) Color.White else Color.Black - + Row( modifier = Modifier .fillMaxWidth() @@ -461,25 +462,28 @@ private fun QuickActionsRow( label = "Camera", backgroundColor = PrimaryBlue, iconColor = Color.White, - onClick = onCameraClick + onClick = onCameraClick, + animationDelay = 0 ) - + // Avatar button QuickActionButton( icon = TablerIcons.User, label = "Avatar", backgroundColor = buttonColor, iconColor = iconColor, - onClick = onAvatarClick + onClick = onAvatarClick, + animationDelay = 50 ) - + // File button QuickActionButton( icon = TablerIcons.File, label = "File", backgroundColor = buttonColor, iconColor = iconColor, - onClick = onFileClick + onClick = onFileClick, + animationDelay = 100 ) } } @@ -490,8 +494,22 @@ private fun QuickActionButton( label: String, backgroundColor: Color, iconColor: Color, - onClick: () -> Unit + onClick: () -> Unit, + animationDelay: Int = 0 ) { + // Bounce animation for icon + val iconScale = remember { Animatable(0f) } + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(animationDelay.toLong()) + iconScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = 0.5f, // Bouncy effect + stiffness = 400f + ) + ) + } + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.clickable( @@ -503,6 +521,10 @@ private fun QuickActionButton( Box( modifier = Modifier .size(56.dp) + .graphicsLayer { + scaleX = iconScale.value + scaleY = iconScale.value + } .clip(CircleShape) .background(backgroundColor), contentAlignment = Alignment.Center @@ -581,6 +603,18 @@ private fun CameraGridItem( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) + + // Bounce animation for camera icon + val iconScale = remember { Animatable(0f) } + LaunchedEffect(Unit) { + iconScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = 0.5f, + stiffness = 400f + ) + ) + } // Check if camera permission is granted - use mutableState for reactivity var hasCameraPermission by remember { @@ -655,7 +689,12 @@ private fun CameraGridItem( imageVector = TablerIcons.Camera, contentDescription = "Camera", tint = Color.White, - modifier = Modifier.size(32.dp) + modifier = Modifier + .size(32.dp) + .graphicsLayer { + scaleX = iconScale.value + scaleY = iconScale.value + } ) } } else { @@ -668,7 +707,12 @@ private fun CameraGridItem( imageVector = TablerIcons.Camera, contentDescription = "Camera", tint = PrimaryBlue, - modifier = Modifier.size(40.dp) + modifier = Modifier + .size(40.dp) + .graphicsLayer { + scaleX = iconScale.value + scaleY = iconScale.value + } ) Spacer(modifier = Modifier.height(8.dp)) Text(