feat: Enhance MultiImageEditorScreen with synchronous image saving and improved editing tools
This commit is contained in:
@@ -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(
|
||||
}
|
||||
}
|
||||
|
||||
// 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<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
|
||||
) {
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user