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 ja.burhanrashid52.photoeditor.SaveSettings
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import kotlin.coroutines.resume
private const val TAG = "ImageEditorScreen" 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 * Rotate/Flip options bar
*/ */
@@ -893,9 +937,51 @@ fun MultiImageEditorScreen(
// Current caption (для текущей страницы) // Current caption (для текущей страницы)
var currentCaption by remember { mutableStateOf("") } 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 // Sync caption when page changes
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
currentCaption = imagesWithCaptions.getOrNull(pagerState.currentPage)?.caption ?: "" 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 { BackHandler {
@@ -910,16 +996,61 @@ fun MultiImageEditorScreen(
// Horizontal Pager для свайпа между фото // Horizontal Pager для свайпа между фото
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize(),
userScrollEnabled = currentTool == EditorTool.NONE // Disable swipe when editing
) { page -> ) { page ->
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Отображаем изображение // PhotoEditorView for editing
AsyncImageLoader( AndroidView(
uri = imagesWithCaptions[page].uri, factory = { ctx ->
modifier = Modifier.fillMaxSize() 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 // Undo button (when drawing)
Spacer(modifier = Modifier.size(48.dp)) 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.imePadding() .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 фото) // Thumbnails strip (если больше 1 фото)
if (imagesWithCaptions.size > 1) { if (imagesWithCaptions.size > 1 && currentTool == EditorTool.NONE) {
LazyRow( LazyRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1006,43 +1209,48 @@ fun MultiImageEditorScreen(
} }
} }
// Caption input bar // Caption input bar (hidden during editing)
Row( AnimatedVisibility(
modifier = Modifier visible = currentTool == EditorTool.NONE,
.fillMaxWidth() enter = fadeIn(),
.padding(horizontal = 12.dp, vertical = 8.dp), exit = fadeOut()
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// Caption text field Row(
Box(
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxWidth()
.clip(RoundedCornerShape(22.dp)) .padding(horizontal = 12.dp, vertical = 8.dp),
.background(Color.White.copy(alpha = 0.15f)) verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
BasicTextField( // Caption text field
value = currentCaption, Box(
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 modifier = Modifier
.fillMaxWidth() .weight(1f)
.padding(horizontal = 16.dp, vertical = 12.dp), .clip(RoundedCornerShape(22.dp))
textStyle = androidx.compose.ui.text.TextStyle( .background(Color.White.copy(alpha = 0.15f))
color = Color.White, ) {
fontSize = 16.sp BasicTextField(
), value = currentCaption,
maxLines = 4, onValueChange = { newCaption ->
decorationBox = { innerTextField -> currentCaption = newCaption
Box { // Update caption for current image
if (currentCaption.isEmpty()) { if (pagerState.currentPage < imagesWithCaptions.size) {
Text( 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...", "Add a caption...",
color = Color.White.copy(alpha = 0.6f), color = Color.White.copy(alpha = 0.6f),
fontSize = 16.sp fontSize = 16.sp
@@ -1062,7 +1270,31 @@ fun MultiImageEditorScreen(
.background(PrimaryBlue) .background(PrimaryBlue)
.clickable(enabled = !isSaving) { .clickable(enabled = !isSaving) {
isSaving = true 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 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)
}
)
} }
} }
} }

View File

@@ -913,12 +913,12 @@ private fun CollapsingProfileHeader(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(top = statusBarHeight) .padding(top = statusBarHeight)
.padding(4.dp) .padding(end = 52.dp, top = 4.dp) // Сдвинуто левее чтобы не накладываться на меню
) { ) {
TextButton(onClick = onSave) { TextButton(onClick = onSave) {
Text( Text(
text = "Save", text = "Save",
color = Color.White, color = if (isDarkTheme) Color.White else Color.Black,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
} }
@@ -1020,12 +1020,12 @@ fun ProfileCard(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.statusBarsPadding() .statusBarsPadding()
.padding(4.dp) .padding(end = 52.dp, top = 4.dp) // Сдвинуто левее
) { ) {
TextButton(onClick = onSave) { TextButton(onClick = onSave) {
Text( Text(
text = "Save", text = "Save",
color = Color.White, color = if (isDarkTheme) Color.White else Color.Black,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
} }