From 116398d00d744417d0b32b3515613d3da6931c03 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 30 Jan 2026 04:24:17 +0500 Subject: [PATCH] feat: Enhance MultiImageEditorScreen with synchronous image saving and improved editing tools --- .../ui/chats/components/ImageEditorScreen.kt | 380 ++++++++++++++++-- .../messenger/ui/settings/ProfileScreen.kt | 8 +- 2 files changed, 340 insertions(+), 48 deletions(-) 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 97c7665..38f0b73 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 @@ -51,8 +51,10 @@ import ja.burhanrashid52.photoeditor.PhotoEditorView import ja.burhanrashid52.photoeditor.SaveSettings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.io.File +import kotlin.coroutines.resume private const val TAG = "ImageEditorScreen" @@ -760,6 +762,48 @@ private suspend fun saveEditedImage( } } +/** + * Save edited image synchronously using suspendCoroutine + */ +private suspend fun saveEditedImageSync( + context: Context, + photoEditor: PhotoEditor? +): Uri? { + if (photoEditor == null) return null + + return withContext(Dispatchers.IO) { + try { + val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}_${(0..9999).random()}.png") + + val saveSettings = SaveSettings.Builder() + .setClearViewsEnabled(false) + .setTransparencyEnabled(true) + .build() + + suspendCancellableCoroutine { continuation -> + photoEditor.saveAsFile( + file.absolutePath, + saveSettings, + object : PhotoEditor.OnSaveListener { + override fun onSuccess(imagePath: String) { + Log.d(TAG, "Image saved sync to: $imagePath") + continuation.resume(Uri.fromFile(File(imagePath))) + } + + override fun onFailure(exception: Exception) { + Log.e(TAG, "Failed to save image sync", exception) + continuation.resume(null) + } + } + ) + } + } catch (e: Exception) { + Log.e(TAG, "Error saving image sync", e) + null + } + } +} + /** * Rotate/Flip options bar */ @@ -893,9 +937,51 @@ fun MultiImageEditorScreen( // 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 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) + } + } + } + } + } + // 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 + } + + // Update brush settings when they change + LaunchedEffect(selectedColor, brushSize) { + val currentPage = pagerState.currentPage + photoEditors[currentPage]?.let { editor -> + if (currentTool == EditorTool.DRAW) { + editor.brushColor = selectedColor.toArgb() + editor.brushSize = brushSize + } + } } BackHandler { @@ -910,16 +996,61 @@ fun MultiImageEditorScreen( // Horizontal Pager для свайпа между фото HorizontalPager( state = pagerState, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + userScrollEnabled = currentTool == EditorTool.NONE // Disable swipe when editing ) { page -> Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - // Отображаем изображение - AsyncImageLoader( - uri = imagesWithCaptions[page].uri, - modifier = Modifier.fillMaxSize() + // PhotoEditorView for editing + AndroidView( + 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() + + withContext(Dispatchers.Main) { + source.setImageBitmap(bitmap) + + // Create PhotoEditor + val editor = PhotoEditor.Builder(ctx, this@apply) + .setPinchTextScalable(true) + .build() + photoEditors[page] = editor + } + } catch (e: Exception) { + Log.e(TAG, "Error loading image for page $page", e) + } + } + } + }, + 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) { + Log.e(TAG, "Error reloading image", e) + } + } + } + } ) } } @@ -960,20 +1091,92 @@ fun MultiImageEditorScreen( } } - // Spacer for balance - Spacer(modifier = Modifier.size(48.dp)) + // Undo button (when drawing) + if (currentTool == EditorTool.DRAW) { + IconButton(onClick = { + photoEditors[pagerState.currentPage]?.undo() + }) { + Icon( + TablerIcons.ArrowBackUp, + contentDescription = "Undo", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } else { + // Spacer for balance + Spacer(modifier = Modifier.size(48.dp)) + } } - // Bottom section - Caption + Send + // Bottom section - Tools + Caption + Send Column( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) .imePadding() - .navigationBarsPadding() ) { + // Color picker bar (when drawing) + AnimatedVisibility( + 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() + } + ) + } + + // 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 фото) - if (imagesWithCaptions.size > 1) { + if (imagesWithCaptions.size > 1 && currentTool == EditorTool.NONE) { LazyRow( modifier = Modifier .fillMaxWidth() @@ -1006,43 +1209,48 @@ fun MultiImageEditorScreen( } } - // Caption input bar - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(10.dp) + // Caption input bar (hidden during editing) + AnimatedVisibility( + visible = currentTool == EditorTool.NONE, + enter = fadeIn(), + exit = fadeOut() ) { - // Caption text field - Box( + Row( modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(22.dp)) - .background(Color.White.copy(alpha = 0.15f)) + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - 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) - } - }, + // Caption text field + Box( 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( + .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 @@ -1062,7 +1270,31 @@ fun MultiImageEditorScreen( .background(PrimaryBlue) .clickable(enabled = !isSaving) { isSaving = true - onSendAll(imagesWithCaptions.toList()) + // Save all edited images before sending + scope.launch { + val savedImages = mutableListOf() + + 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 ) { @@ -1084,6 +1316,66 @@ fun MultiImageEditorScreen( } } } + } + + // 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) + } + ) } } } 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 cfac93b..1ed43d0 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 @@ -913,12 +913,12 @@ private fun CollapsingProfileHeader( modifier = Modifier .align(Alignment.TopEnd) .padding(top = statusBarHeight) - .padding(4.dp) + .padding(end = 52.dp, top = 4.dp) // Сдвинуто левее чтобы не накладываться на меню ) { TextButton(onClick = onSave) { Text( text = "Save", - color = Color.White, + color = if (isDarkTheme) Color.White else Color.Black, fontWeight = FontWeight.SemiBold ) } @@ -1020,12 +1020,12 @@ fun ProfileCard( modifier = Modifier .align(Alignment.TopEnd) .statusBarsPadding() - .padding(4.dp) + .padding(end = 52.dp, top = 4.dp) // Сдвинуто левее ) { TextButton(onClick = onSave) { Text( text = "Save", - color = Color.White, + color = if (isDarkTheme) Color.White else Color.Black, fontWeight = FontWeight.SemiBold ) }