feat: Enhance MultiImageEditorScreen with synchronous image saving and improved editing tools

This commit is contained in:
k1ngsterr1
2026-01-30 04:24:17 +05:00
parent c4424683cb
commit 116398d00d
2 changed files with 340 additions and 48 deletions

View File

@@ -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<Int, PhotoEditor?>() }
val photoEditorViews = remember { mutableStateMapOf<Int, PhotoEditorView?>() }
// 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(
}
}
// 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,7 +1209,12 @@ fun MultiImageEditorScreen(
}
}
// Caption input bar
// Caption input bar (hidden during editing)
AnimatedVisibility(
visible = currentTool == EditorTool.NONE,
enter = fadeIn(),
exit = fadeOut()
) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -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<ImageWithCaption>()
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
) {
@@ -1085,6 +1317,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)
}
)
}
}
}

View File

@@ -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
)
}