diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt index 69606a65..ffa71781 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt @@ -1,7 +1,8 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.chats.currentChat.editor.photo +import android.graphics.BitmapFactory import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -23,6 +24,7 @@ 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.graphics.* import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector @@ -36,9 +38,12 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.monogram.presentation.R import org.monogram.presentation.features.chats.currentChat.editor.photo.components.* +import org.monogram.presentation.features.chats.currentChat.editor.photo.crop.* import java.io.File enum class EditorTool(val labelRes: Int, val icon: ImageVector) { @@ -50,6 +55,9 @@ enum class EditorTool(val labelRes: Int, val icon: ImageVector) { ERASER(R.string.photo_editor_tool_eraser, Icons.Rounded.CleaningServices) } +private const val MinImageScale = 0.5f +private const val MaxImageScale = 10f + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PhotoEditorScreen( @@ -59,8 +67,9 @@ fun PhotoEditorScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val density = LocalDensity.current - var currentTool by remember { mutableStateOf(EditorTool.NONE) } + var currentTool by remember { mutableStateOf(EditorTool.TRANSFORM) } val paths = remember { mutableStateListOf() } val pathsRedo = remember { mutableStateListOf() } @@ -70,6 +79,7 @@ fun PhotoEditorScreen( var brushSize by remember { mutableFloatStateOf(15f) } var currentFilter by remember { mutableStateOf(null) } + var imageRotation by remember { mutableFloatStateOf(0f) } var imageScale by remember { mutableFloatStateOf(1f) } var imageOffset by remember { mutableStateOf(Offset.Zero) } @@ -81,12 +91,171 @@ fun PhotoEditorScreen( var isSaving by remember { mutableStateOf(false) } var showDiscardDialog by remember { mutableStateOf(false) } + val imageSize by produceState(initialValue = IntSize.Zero, key1 = imagePath) { + value = withContext(Dispatchers.IO) { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(imagePath, options) + IntSize(options.outWidth.coerceAtLeast(0), options.outHeight.coerceAtLeast(0)) + } + } + + + val pivot by remember(canvasSize) { + derivedStateOf { Offset(canvasSize.width / 2f, canvasSize.height / 2f) } + } + + val cropState = rememberCropEditorState( + canvasSize = canvasSize, + imageSize = imageSize, + transformPivot = pivot, + imageScale = imageScale, + imageRotation = imageRotation, + imageOffset = imageOffset + ) + + + fun fillAreaAfterResize() { + val crop = cropState.cropRect + if (crop.width <= 0f || crop.height <= 0f || canvasSize.width <= 0 || canvasSize.height <= 0) return + + val currentAspect = crop.width / crop.height + val targetCropRect = calculateTargetFillRect(canvasSize, currentAspect) + if (targetCropRect == Rect.Zero) return + + + val scaleFactor = maxOf( + targetCropRect.width / crop.width, + targetCropRect.height / crop.height + ) + val targetScale = (imageScale * scaleFactor).coerceIn(MinImageScale, MaxImageScale) + val z = if (imageScale != 0f) targetScale / imageScale else 1f + + + + + + val targetOffset = Offset( + x = (targetCropRect.center.x - pivot.x) - z * (crop.center.x - pivot.x - imageOffset.x), + y = (targetCropRect.center.y - pivot.y) - z * (crop.center.y - pivot.y - imageOffset.y) + ) + + val targetImageBounds = calculateScalarTransformedBounds( + baseBounds = cropState.imageBounds, + scale = targetScale, + rotationDegrees = imageRotation, + offset = targetOffset, + pivot = pivot + ) + val safeTargetCropRect = constrainCropRectToImage( + currentCropRect = crop, + candidateRect = targetCropRect, + visibleBounds = targetImageBounds, + minCropSizePx = cropState.minCropSizePx, + baseBounds = cropState.imageBounds, + scale = targetScale, + rotationDegrees = imageRotation, + offset = targetOffset, + pivot = pivot + ) + + + scope.launch { + val startCrop = crop + val startScale = imageScale + val startOffset = imageOffset + val anim = androidx.compose.animation.core.Animatable(0f) + anim.animateTo(1f, androidx.compose.animation.core.tween(200)) { + val t = value + cropState.setCropRect( + Rect( + left = startCrop.left + (safeTargetCropRect.left - startCrop.left) * t, + top = startCrop.top + (safeTargetCropRect.top - startCrop.top) * t, + right = startCrop.right + (safeTargetCropRect.right - startCrop.right) * t, + bottom = startCrop.bottom + (safeTargetCropRect.bottom - startCrop.bottom) * t + ) + ) + imageScale = startScale + (targetScale - startScale) * t + imageOffset = Offset( + x = startOffset.x + (targetOffset.x - startOffset.x) * t, + y = startOffset.y + (targetOffset.y - startOffset.y) * t + ) + } + } + } + + val shouldConstrain by remember(currentTool) { + derivedStateOf { currentTool == EditorTool.TRANSFORM || currentTool == EditorTool.NONE } + } + + fun applyTransform(centroid: Offset, pan: Offset, zoom: Float) { + val effectiveMinScale = if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + minimumScaleToCoverCrop( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + currentScale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = pivot + ).coerceAtLeast(MinImageScale) + } else { + MinImageScale + } + + val newScale = (imageScale * zoom).coerceIn(effectiveMinScale, MaxImageScale) + val actualZoom = if (imageScale != 0f) newScale / imageScale else 1f + + val offsetAfterZoom = offsetForZoomAroundAnchor(imageOffset, pivot, centroid, actualZoom) + val newOffset = offsetAfterZoom + pan + + imageScale = newScale + imageOffset = if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + clampOffsetToCoverCrop( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + scale = newScale, + rotationDegrees = imageRotation, + offset = newOffset, + pivot = pivot + ) + } else { + newOffset + } + } + + fun applyRotation(newRotation: Float) { + val deltaAngle = newRotation - imageRotation + + val anchor = if (cropState.cropRect != Rect.Zero) cropState.cropRect.center else pivot + val newOffset = offsetForRotationAroundAnchor(imageOffset, pivot, anchor, deltaAngle) + + imageRotation = newRotation + + + if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + val (fittedScale, fittedOffset) = fitContentInBounds( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + scale = imageScale, + rotationDegrees = newRotation, + offset = newOffset, + pivot = pivot, + minScale = MinImageScale, + maxScale = MaxImageScale + ) + imageScale = fittedScale + imageOffset = fittedOffset + } else { + imageOffset = newOffset + } + } + val hasChanges by remember { derivedStateOf { paths.isNotEmpty() || textElements.isNotEmpty() || currentFilter != null || - imageRotation != 0f || + (cropState.cropRect != Rect.Zero && cropState.cropRect != cropState.defaultCropRect) || + normalizeRotationDegrees(imageRotation) != 0f || imageScale != 1f || imageOffset != Offset.Zero } @@ -120,7 +289,9 @@ fun PhotoEditorScreen( textElements, currentFilter, canvasSize, - imageRotation, + cropState.cropRect, + pivot, + normalizeRotationDegrees(imageRotation), imageScale, imageOffset ) @@ -151,9 +322,7 @@ fun PhotoEditorScreen( tonalElevation = 3.dp, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) ) { - Column( - modifier = Modifier.navigationBarsPadding() - ) { + Column(modifier = Modifier.navigationBarsPadding()) { AnimatedContent( targetState = currentTool, label = "ToolOptions", @@ -162,24 +331,22 @@ fun PhotoEditorScreen( Box( modifier = Modifier .fillMaxWidth() - .heightIn(min = 100.dp), + .heightIn(min = 84.dp), contentAlignment = Alignment.Center ) { when (tool) { EditorTool.TRANSFORM -> { TransformControls( rotation = imageRotation, - scale = imageScale, - onRotationChange = { imageRotation = it }, - onScaleChange = { imageScale = it }, + onRotationChange = { newRotation -> applyRotation(newRotation) }, onReset = { imageRotation = 0f imageScale = 1f imageOffset = Offset.Zero + cropState.reset() } ) } - EditorTool.DRAW, EditorTool.ERASER -> { DrawControls( isEraser = tool == EditorTool.ERASER, @@ -189,7 +356,6 @@ fun PhotoEditorScreen( onSizeChange = { brushSize = it } ) } - EditorTool.FILTER -> { FilterControls( imagePath = imagePath, @@ -197,7 +363,6 @@ fun PhotoEditorScreen( onFilterSelect = { currentFilter = it } ) } - EditorTool.TEXT -> { Button( onClick = { @@ -211,7 +376,6 @@ fun PhotoEditorScreen( Text(stringResource(R.string.photo_editor_action_add_text)) } } - else -> { Text( stringResource(R.string.photo_editor_label_select_tool), @@ -223,10 +387,7 @@ fun PhotoEditorScreen( } } - NavigationBar( - containerColor = Color.Transparent, - tonalElevation = 0.dp - ) { + NavigationBar(containerColor = Color.Transparent, tonalElevation = 0.dp) { EditorTool.entries.forEach { tool -> val label = stringResource(tool.labelRes) NavigationBarItem( @@ -255,180 +416,168 @@ fun PhotoEditorScreen( .background(Color.Black) .onGloballyPositioned { canvasSize = it.size } ) { - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = imageScale, - scaleY = imageScale, - rotationZ = imageRotation, - translationX = imageOffset.x, - translationY = imageOffset.y - ) - .pointerInput(currentTool) { - if (currentTool == EditorTool.TRANSFORM || currentTool == EditorTool.NONE) { - detectTransformGestures { _, pan, zoom, rotation -> - imageScale *= zoom - imageRotation += rotation - imageOffset += pan - } - } - } - ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(File(imagePath)) - .build(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - colorFilter = currentFilter?.let { ColorFilter.colorMatrix(it.colorMatrix) } - ) - - Canvas( + Box(modifier = Modifier.fillMaxSize()) { + + Box( modifier = Modifier .fillMaxSize() + .graphicsLayer( + scaleX = imageScale, + scaleY = imageScale, + rotationZ = imageRotation, + translationX = imageOffset.x, + translationY = imageOffset.y, + transformOrigin = TransformOrigin.Center + ) .pointerInput(currentTool) { - if (currentTool == EditorTool.DRAW || currentTool == EditorTool.ERASER) { - detectDragGestures( - onDragStart = { offset -> - val path = Path().apply { moveTo(offset.x, offset.y) } - paths.add( - DrawnPath( - path = path, - color = if (currentTool == EditorTool.ERASER) Color.Transparent else selectedColor, - strokeWidth = brushSize, - isEraser = currentTool == EditorTool.ERASER - ) - ) - pathsRedo.clear() - }, - onDrag = { change, _ -> - change.consume() - val index = paths.lastIndex - if (index == -1) return@detectDragGestures - - val currentPathData = paths[index] - val x1 = change.previousPosition.x - val y1 = change.previousPosition.y - val x2 = change.position.x - val y2 = change.position.y - - currentPathData.path.quadraticTo( - x1, y1, (x1 + x2) / 2, (y1 + y2) / 2 - ) - - paths.add(paths.removeAt(index)) - } - ) + if (currentTool == EditorTool.NONE) { + detectTransformGestures { centroid, pan, zoom, _ -> + applyTransform(centroid, pan, zoom) + } } } ) { - paths.forEach { pathData -> - drawPath( - path = pathData.path, - color = pathData.color, - alpha = pathData.alpha, - style = Stroke( - width = pathData.strokeWidth, - cap = StrokeCap.Round, - join = StrokeJoin.Round - ), - blendMode = if (pathData.isEraser) BlendMode.Clear else BlendMode.SrcOver - ) - } - } - - textElements.forEach { element -> - val density = LocalDensity.current - - var currentOffset by remember(element.id) { - mutableStateOf( - if (element.offset == Offset.Zero) Offset( - canvasSize.width / 2f, - canvasSize.height / 2f - ) else element.offset - ) - } - var currentScale by remember(element.id) { mutableStateOf(element.scale) } - var currentRotation by remember(element.id) { mutableStateOf(element.rotation) } + AsyncImage( + model = ImageRequest.Builder(context).data(File(imagePath)).build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + colorFilter = currentFilter?.let { ColorFilter.colorMatrix(it.colorMatrix) } + ) - Box( + Canvas( modifier = Modifier - .offset( - x = with(density) { currentOffset.x.toDp() }, - y = with(density) { currentOffset.y.toDp() } + .fillMaxSize() + .pointerInput(currentTool) { + if (currentTool == EditorTool.DRAW || currentTool == EditorTool.ERASER) { + detectDragGestures( + onDragStart = { offset -> + val path = Path().apply { moveTo(offset.x, offset.y) } + paths.add( + DrawnPath( + path = path, + color = if (currentTool == EditorTool.ERASER) Color.Transparent else selectedColor, + strokeWidth = brushSize, + isEraser = currentTool == EditorTool.ERASER + ) + ) + pathsRedo.clear() + }, + onDrag = { change, _ -> + change.consume() + val index = paths.lastIndex + if (index == -1) return@detectDragGestures + val cur = paths[index] + val x1 = change.previousPosition.x + val y1 = change.previousPosition.y + val x2 = change.position.x + val y2 = change.position.y + cur.path.quadraticTo(x1, y1, (x1 + x2) / 2, (y1 + y2) / 2) + paths.add(paths.removeAt(index)) + } + ) + } + } + ) { + paths.forEach { pathData -> + drawPath( + path = pathData.path, + color = pathData.color, + alpha = pathData.alpha, + style = Stroke(width = pathData.strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round), + blendMode = if (pathData.isEraser) BlendMode.Clear else BlendMode.SrcOver ) - .graphicsLayer( - scaleX = currentScale, - scaleY = currentScale, - rotationZ = currentRotation, - translationX = -with(density) { 100.dp.toPx() }, - translationY = -with(density) { 25.dp.toPx() } + } + } + + textElements.forEach { element -> + var currentOffset by remember(element.id) { + mutableStateOf( + if (element.offset == Offset.Zero) Offset(canvasSize.width / 2f, canvasSize.height / 2f) + else element.offset ) - .pointerInput(currentTool, element.id) { - if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { - detectTransformGestures( - onGesture = { _, pan, zoom, rotation -> + } + var currentScale by remember(element.id) { mutableStateOf(element.scale) } + var currentRotation by remember(element.id) { mutableStateOf(element.rotation) } + + Box( + modifier = Modifier + .offset( + x = with(density) { currentOffset.x.toDp() }, + y = with(density) { currentOffset.y.toDp() } + ) + .graphicsLayer( + scaleX = currentScale, + scaleY = currentScale, + rotationZ = currentRotation, + translationX = -with(density) { 100.dp.toPx() }, + translationY = -with(density) { 25.dp.toPx() } + ) + .pointerInput(currentTool, element.id) { + if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { + detectTransformGestures { _, pan, zoom, rotation -> currentOffset += pan currentScale *= zoom currentRotation += rotation } - ) + } } - } - .pointerInput(currentTool, element.id) { - if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { - detectTapGestures(onTap = { - if (currentTool == EditorTool.TEXT) { - editingTextElement = element - selectedColor = element.color - showTextDialog = true - } - }) + .pointerInput(currentTool, element.id) { + if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { + detectTapGestures(onTap = { + if (currentTool == EditorTool.TEXT) { + editingTextElement = element + selectedColor = element.color + showTextDialog = true + } + }) + } + } + ) { + LaunchedEffect(currentOffset, currentScale, currentRotation) { + val idx = textElements.indexOfFirst { it.id == element.id } + if (idx != -1 && (textElements[idx].offset != currentOffset || textElements[idx].rotation != currentRotation)) { + textElements[idx] = textElements[idx].copy( + offset = currentOffset, scale = currentScale, rotation = currentRotation + ) } } - ) { - LaunchedEffect(currentOffset, currentScale, currentRotation) { - val idx = textElements.indexOfFirst { it.id == element.id } - if (idx != -1 && (textElements[idx].offset != currentOffset || textElements[idx].rotation != currentRotation)) { - textElements[idx] = textElements[idx].copy( - offset = currentOffset, - scale = currentScale, - rotation = currentRotation - ) - } - } - - Text( - text = element.text, - color = element.color, - style = MaterialTheme.typography.headlineLarge.copy( - shadow = Shadow( - color = Color.Black, - offset = Offset(2f, 2f), - blurRadius = 4f + Text( + text = element.text, + color = element.color, + style = MaterialTheme.typography.headlineLarge.copy( + shadow = Shadow(color = Color.Black, offset = Offset(2f, 2f), blurRadius = 4f) ) ) - ) - - if (currentTool == EditorTool.TEXT) { - Box( - modifier = Modifier - .matchParentSize() - .border(1.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) - ) + if (currentTool == EditorTool.TEXT) { + Box( + modifier = Modifier + .matchParentSize() + .border(1.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) + ) + } } } } } + if (currentTool == EditorTool.TRANSFORM && cropState.cropRect != Rect.Zero) { + CropScrim(cropRect = cropState.cropRect) + } + AnimatedVisibility( - visible = currentTool == EditorTool.TRANSFORM, + visible = currentTool == EditorTool.TRANSFORM && cropState.cropRect != Rect.Zero, enter = fadeIn(), exit = fadeOut() ) { - CropOverlay() + CropOverlay( + cropRect = cropState.cropRect, + bounds = cropState.currentImageBounds, + minCropSizePx = cropState.minCropSizePx, + onCropRectChange = cropState.updateCropRect, + onContentTransform = { centroid, pan, zoom -> applyTransform(centroid, pan, zoom) }, + onResizeEnded = { fillAreaAfterResize() } + ) } if (isSaving) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt index 344bb578..1b534410 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.* import androidx.annotation.StringRes import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorMatrix @@ -17,6 +18,8 @@ import java.io.FileOutputStream import java.util.* import android.graphics.Canvas as AndroidCanvas import android.graphics.Paint as AndroidPaint +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withTranslation data class DrawnPath( val path: Path, @@ -114,11 +117,17 @@ suspend fun saveImage( textElements: List, filter: ImageFilter?, canvasSize: IntSize, + cropRect: Rect, + transformPivot: Offset = cropRect.center, imageRotation: Float = 0f, imageScale: Float = 1f, imageOffset: Offset = Offset.Zero ): String? = withContext(Dispatchers.IO) { try { + if (canvasSize.width <= 0 || canvasSize.height <= 0 || cropRect.width <= 0f || cropRect.height <= 0f) { + return@withContext null + } + val options = BitmapFactory.Options().apply { inMutable = true } var bitmap = BitmapFactory.decodeFile(originalPath, options) ?: return@withContext null @@ -150,66 +159,84 @@ suspend fun saveImage( dx = (screenW - (bitmapW * baseScale)) / 2f } - val resultBitmap = Bitmap.createBitmap(canvasSize.width, canvasSize.height, Bitmap.Config.ARGB_8888) + val exportScale = if (baseScale > 0f) 1f / baseScale else 1f + val resultBitmap = createBitmap( + (cropRect.width * exportScale).toInt().coerceAtLeast(1), + (cropRect.height * exportScale).toInt().coerceAtLeast(1) + ) val canvas = AndroidCanvas(resultBitmap) + val transformPivotX = transformPivot.x * exportScale + val transformPivotY = transformPivot.y * exportScale + val scaledCropLeft = cropRect.left * exportScale + val scaledCropTop = cropRect.top * exportScale + val scaledImageOffset = Offset(imageOffset.x * exportScale, imageOffset.y * exportScale) + + canvas.translate(-scaledCropLeft, -scaledCropTop) + + canvas.withTranslation(scaledImageOffset.x + transformPivotX, scaledImageOffset.y + transformPivotY) { + rotate(imageRotation) + scale(imageScale, imageScale) + translate(-transformPivotX, -transformPivotY) + + val imagePaint = AndroidPaint().apply { + isAntiAlias = true + if (filter != null) { + colorFilter = android.graphics.ColorMatrixColorFilter(filter.colorMatrix.values) + } + } + val destRect = RectF( + dx * exportScale, + dy * exportScale, + dx * exportScale + bitmapW, + dy * exportScale + bitmapH + ) + drawBitmap(bitmap, null, destRect, imagePaint) - canvas.save() - canvas.translate(imageOffset.x + screenW / 2f, imageOffset.y + screenH / 2f) - canvas.rotate(imageRotation) - canvas.scale(imageScale, imageScale) - canvas.translate(-screenW / 2f, -screenH / 2f) + val pathPaint = AndroidPaint().apply { + isAntiAlias = true + style = AndroidPaint.Style.STROKE + strokeCap = AndroidPaint.Cap.ROUND + strokeJoin = AndroidPaint.Join.ROUND + } - val imagePaint = AndroidPaint().apply { - isAntiAlias = true - if (filter != null) { - colorFilter = android.graphics.ColorMatrixColorFilter(filter.colorMatrix.values) + paths.forEach { pathData -> + if (pathData.isEraser) { + pathPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } else { + pathPaint.xfermode = null + pathPaint.color = pathData.color.toArgb() + pathPaint.alpha = (pathData.alpha * 255).toInt() + } + pathPaint.strokeWidth = pathData.strokeWidth * exportScale + val scaledPath = android.graphics.Path(pathData.path.asAndroidPath()) + scaledPath.transform( + android.graphics.Matrix().apply { setScale(exportScale, exportScale) } + ) + drawPath(scaledPath, pathPaint) } - } - val destRect = RectF(dx, dy, dx + bitmapW * baseScale, dy + bitmapH * baseScale) - canvas.drawBitmap(bitmap, null, destRect, imagePaint) - - val pathPaint = AndroidPaint().apply { - isAntiAlias = true - style = AndroidPaint.Style.STROKE - strokeCap = AndroidPaint.Cap.ROUND - strokeJoin = AndroidPaint.Join.ROUND - } - paths.forEach { pathData -> - if (pathData.isEraser) { - pathPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) - } else { - pathPaint.xfermode = null - pathPaint.color = pathData.color.toArgb() - pathPaint.alpha = (pathData.alpha * 255).toInt() + val textPaint = AndroidPaint().apply { + isAntiAlias = true + typeface = Typeface.DEFAULT_BOLD } - pathPaint.strokeWidth = pathData.strokeWidth - canvas.drawPath(pathData.path.asAndroidPath(), pathPaint) - } - val textPaint = AndroidPaint().apply { - isAntiAlias = true - typeface = Typeface.DEFAULT_BOLD - } + textElements.forEach { element -> + textPaint.color = element.color.toArgb() + textPaint.textSize = 64f * element.scale * exportScale - textElements.forEach { element -> - textPaint.color = element.color.toArgb() - textPaint.textSize = 64f * element.scale + withTranslation(element.offset.x * exportScale, element.offset.y * exportScale) { + rotate(Math.toDegrees(element.rotation.toDouble()).toFloat()) - canvas.save() - canvas.translate(element.offset.x, element.offset.y) - canvas.rotate(Math.toDegrees(element.rotation.toDouble()).toFloat()) + val textWidth = textPaint.measureText(element.text) + val fontMetrics = textPaint.fontMetrics + val textHeight = fontMetrics.descent - fontMetrics.ascent - val textWidth = textPaint.measureText(element.text) - val fontMetrics = textPaint.fontMetrics - val textHeight = fontMetrics.descent - fontMetrics.ascent + drawText(element.text, -textWidth / 2f, textHeight / 4f, textPaint) + } + } - canvas.drawText(element.text, -textWidth / 2f, textHeight / 4f, textPaint) - canvas.restore() } - canvas.restore() - val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.jpg") FileOutputStream(file).use { out -> resultBitmap.compress(Bitmap.CompressFormat.JPEG, 95, out) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt deleted file mode 100644 index e9841bfd..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt +++ /dev/null @@ -1,102 +0,0 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.components - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp - -@Composable -fun CropOverlay( - modifier: Modifier = Modifier -) { - Canvas( - modifier = modifier - .fillMaxSize() - .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) - ) { - val width = size.width - val height = size.height - - val padding = 32.dp.toPx() - val cropWidth = width - padding * 2 - val cropHeight = height - padding * 2 - - val rect = Rect( - offset = Offset(padding, padding), - size = Size(cropWidth, cropHeight) - ) - - drawRect( - color = Color.Black.copy(alpha = 0.7f), - size = size - ) - - drawRect( - color = Color.Transparent, - topLeft = rect.topLeft, - size = rect.size, - blendMode = BlendMode.Clear - ) - - val strokeWidth = 1.dp.toPx() - val gridColor = Color.White.copy(alpha = 0.5f) - - drawLine( - color = gridColor, - start = Offset(rect.left + rect.width / 3, rect.top), - end = Offset(rect.left + rect.width / 3, rect.bottom), - strokeWidth = strokeWidth - ) - drawLine( - color = gridColor, - start = Offset(rect.left + rect.width * 2 / 3, rect.top), - end = Offset(rect.left + rect.width * 2 / 3, rect.bottom), - strokeWidth = strokeWidth - ) - - drawLine( - color = gridColor, - start = Offset(rect.left, rect.top + rect.height / 3), - end = Offset(rect.right, rect.top + rect.height / 3), - strokeWidth = strokeWidth - ) - drawLine( - color = gridColor, - start = Offset(rect.left, rect.top + rect.height * 2 / 3), - end = Offset(rect.right, rect.top + rect.height * 2 / 3), - strokeWidth = strokeWidth - ) - - val cornerLen = 24.dp.toPx() - val cornerStroke = 3.dp.toPx() - val cornerColor = Color.White - - drawLine(cornerColor, rect.topLeft, rect.topLeft.copy(x = rect.left + cornerLen), cornerStroke) - drawLine(cornerColor, rect.topLeft, rect.topLeft.copy(y = rect.top + cornerLen), cornerStroke) - - drawLine(cornerColor, rect.topRight, rect.topRight.copy(x = rect.right - cornerLen), cornerStroke) - drawLine(cornerColor, rect.topRight, rect.topRight.copy(y = rect.top + cornerLen), cornerStroke) - - drawLine(cornerColor, rect.bottomLeft, rect.bottomLeft.copy(x = rect.left + cornerLen), cornerStroke) - drawLine(cornerColor, rect.bottomLeft, rect.bottomLeft.copy(y = rect.bottom - cornerLen), cornerStroke) - - drawLine(cornerColor, rect.bottomRight, rect.bottomRight.copy(x = rect.right - cornerLen), cornerStroke) - drawLine(cornerColor, rect.bottomRight, rect.bottomRight.copy(y = rect.bottom - cornerLen), cornerStroke) - - drawRect( - color = Color.White.copy(alpha = 0.8f), - topLeft = rect.topLeft, - size = rect.size, - style = Stroke(width = 1.dp.toPx()) - ) - } -} \ No newline at end of file diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt index 99f663fe..553ee2b4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt @@ -1,79 +1,177 @@ package org.monogram.presentation.features.chats.currentChat.editor.photo.components +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.RotateLeft -import androidx.compose.material.icons.rounded.RotateRight import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.monogram.presentation.R +import kotlin.math.absoluteValue +import kotlin.math.floor +import kotlin.math.roundToInt @Composable fun TransformControls( rotation: Float, - scale: Float, onRotationChange: (Float) -> Unit, - onScaleChange: (Float) -> Unit, onReset: () -> Unit ) { + val normalizedRotation = normalizeRotationDegrees(rotation) + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly + verticalAlignment = Alignment.Bottom ) { - FilledTonalIconButton(onClick = { onRotationChange(rotation - 90f) }) { - Icon( - Icons.Rounded.RotateLeft, - contentDescription = stringResource(R.string.photo_editor_action_rotate_left) + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "${normalizedRotation.roundToInt()}°", + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + RotationWheel( + angle = rotation, + onAngleChange = onRotationChange, + modifier = Modifier.fillMaxWidth() ) } - OutlinedButton( + Spacer(modifier = Modifier.width(12.dp)) + + OutlinedIconButton( onClick = onReset, - contentPadding = PaddingValues(horizontal = 16.dp) + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface + ) ) { - Icon(Icons.Rounded.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.photo_editor_action_reset)) - } - - FilledTonalIconButton(onClick = { onRotationChange(rotation + 90f) }) { Icon( - Icons.Rounded.RotateRight, - contentDescription = stringResource(R.string.photo_editor_action_rotate_right) + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.photo_editor_action_reset) ) } } + } +} - Spacer(modifier = Modifier.height(12.dp)) +@Composable +private fun RotationWheel( + angle: Float, + onAngleChange: (Float) -> Unit, + modifier: Modifier = Modifier +) { + val onSurface = MaterialTheme.colorScheme.onSurface + val primary = MaterialTheme.colorScheme.primary + var visualAngle by remember { mutableFloatStateOf(angle) } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + LaunchedEffect(angle) { + visualAngle = closestEquivalentAngle(angle, visualAngle) + } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(58.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + shape = MaterialTheme.shapes.large + ) + .pointerInput(Unit) { + var dragAngle = visualAngle + detectDragGestures( + onDragStart = { + dragAngle = visualAngle + } + ) { change, dragAmount -> + change.consume() + dragAngle -= dragAmount.x * 0.1f + visualAngle = dragAngle + onAngleChange(dragAngle) + } + } + .padding(horizontal = 12.dp, vertical = 8.dp) ) { - Text( - stringResource(R.string.photo_editor_label_zoom), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(48.dp) - ) - Slider( - value = scale, - onValueChange = onScaleChange, - valueRange = 0.5f..3f, - modifier = Modifier.weight(1f) - ) + Canvas(modifier = Modifier.fillMaxWidth().height(42.dp)) { + val centerX = size.width / 2f + val bottom = size.height + val tickSpacing = 6f + val minorStep = 5 + val majorStep = 45 + val currentTick = visualAngle / minorStep + val centerTick = floor(currentTick).toInt() + val visibleTickCount = (size.width / tickSpacing / 2f).roundToInt() + 4 + + for (relativeTick in -visibleTickCount..visibleTickCount) { + val tickIndex = centerTick + relativeTick + val tickAngle = tickIndex * minorStep + val x = centerX + (tickIndex - currentTick) * tickSpacing + if (x < 0f || x > size.width) continue + + val distanceRatio = ((x - centerX).absoluteValue / centerX).coerceIn(0f, 1f) + val alpha = 1f - distanceRatio * 0.8f + val isMajor = tickAngle % majorStep == 0 + val isMedium = tickAngle % 15 == 0 + val tickHeight = when { + isMajor -> size.height * 0.62f + isMedium -> size.height * 0.45f + else -> size.height * 0.28f + } + val strokeWidth = when { + isMajor -> 3f + isMedium -> 2.5f + else -> 1.5f + } + + drawLine( + color = onSurface.copy(alpha = alpha), + start = Offset(x, bottom - tickHeight), + end = Offset(x, bottom), + strokeWidth = strokeWidth + ) + } + + drawLine( + color = primary, + start = Offset(centerX, 0f), + end = Offset(centerX, bottom), + strokeWidth = 4f + ) + } } } } + +internal fun normalizeRotationDegrees(value: Float): Float { + var normalized = value % 360f + if (normalized > 180f) normalized -= 360f + if (normalized < -180f) normalized += 360f + return normalized +} + +private fun closestEquivalentAngle(normalizedAngle: Float, referenceAngle: Float): Float { + val turns = ((referenceAngle - normalizedAngle) / 360f).roundToInt() + return normalizedAngle + turns * 360f +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt new file mode 100644 index 00000000..ad405297 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt @@ -0,0 +1,145 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +@Stable +class CropEditorState internal constructor( + val minCropSizePx: Float, + val imageBounds: Rect, + val currentImageBounds: Rect, + val defaultCropRect: Rect, + val cropRect: Rect, + val updateCropRect: (Rect) -> Unit, + val setCropRect: (Rect) -> Unit, + val reset: () -> Unit +) + +fun calculateTargetFillRect(canvasSize: IntSize, aspectRatio: Float): Rect { + if (canvasSize.width <= 0 || canvasSize.height <= 0 || aspectRatio <= 0f) return Rect.Zero + val cw = canvasSize.width.toFloat() + val ch = canvasSize.height.toFloat() + val centerX = cw / 2f + val centerY = ch / 2f + + val w: Float + val h: Float + if (ch * aspectRatio > cw) { + w = cw + h = cw / aspectRatio + } else { + h = ch + w = ch * aspectRatio + } + return Rect(centerX - w / 2f, centerY - h / 2f, centerX + w / 2f, centerY + h / 2f) +} + +@Composable +fun rememberCropEditorState( + canvasSize: IntSize, + imageSize: IntSize, + transformPivot: Offset, + imageScale: Float, + imageRotation: Float, + imageOffset: Offset +): CropEditorState { + val density = LocalDensity.current + val minCropSizePx = remember(density) { with(density) { 96.dp.toPx() } } + + val imageBounds by remember(canvasSize, imageSize) { + derivedStateOf { calculateCropRect(canvasSize, imageSize) } + } + val defaultCropRect by remember(imageBounds) { + derivedStateOf { imageBounds } + } + + var cropRect by remember { mutableStateOf(Rect.Zero) } + var previousImageBounds by remember { mutableStateOf(Rect.Zero) } + + val currentImageBounds by remember(imageBounds, imageScale, imageRotation, imageOffset, transformPivot) { + derivedStateOf { + if (imageBounds != Rect.Zero) { + calculateScalarTransformedBounds( + baseBounds = imageBounds, + scale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = transformPivot + ) + } else { + imageBounds + } + } + } + + LaunchedEffect(imageBounds) { + if (imageBounds == Rect.Zero) { + cropRect = Rect.Zero + previousImageBounds = Rect.Zero + } else if (cropRect == Rect.Zero || previousImageBounds == Rect.Zero) { + cropRect = imageBounds + previousImageBounds = imageBounds + } else if (previousImageBounds != imageBounds) { + cropRect = constrainCropRect( + cropRect = remapRectToBounds(cropRect, previousImageBounds, imageBounds), + bounds = imageBounds, + minCropSizePx = minCropSizePx + ) + previousImageBounds = imageBounds + } + } + + return CropEditorState( + minCropSizePx = minCropSizePx, + imageBounds = imageBounds, + currentImageBounds = currentImageBounds, + defaultCropRect = defaultCropRect, + cropRect = cropRect, + updateCropRect = { candidate -> + cropRect = constrainCropRectToImage( + currentCropRect = cropRect, + candidateRect = candidate, + visibleBounds = currentImageBounds, + minCropSizePx = minCropSizePx, + baseBounds = imageBounds, + scale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = transformPivot + ) + }, + setCropRect = { rect -> + cropRect = rect + }, + reset = { + cropRect = defaultCropRect + } + ) +} + +private fun remapRectToBounds(rect: Rect, fromBounds: Rect, toBounds: Rect): Rect { + if (rect == Rect.Zero || fromBounds.width <= 0f || fromBounds.height <= 0f) return toBounds + + val leftFraction = (rect.left - fromBounds.left) / fromBounds.width + val topFraction = (rect.top - fromBounds.top) / fromBounds.height + val rightFraction = (rect.right - fromBounds.left) / fromBounds.width + val bottomFraction = (rect.bottom - fromBounds.top) / fromBounds.height + + return Rect( + left = toBounds.left + toBounds.width * leftFraction, + top = toBounds.top + toBounds.height * topFraction, + right = toBounds.left + toBounds.width * rightFraction, + bottom = toBounds.top + toBounds.height * bottomFraction + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt new file mode 100644 index 00000000..d742f860 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt @@ -0,0 +1,339 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +private const val GeometryEpsilon = 0.001f + +private fun contentToScreen( + p: Offset, scale: Float, cosR: Float, sinR: Float, offset: Offset, pivot: Offset +): Offset { + val dx = p.x - pivot.x + val dy = p.y - pivot.y + return Offset( + x = pivot.x + scale * (dx * cosR - dy * sinR) + offset.x, + y = pivot.y + scale * (dx * sinR + dy * cosR) + offset.y + ) +} + +private fun screenToContent( + screen: Offset, scale: Float, cosR: Float, sinR: Float, offset: Offset, pivot: Offset +): Offset { + val sx = (screen.x - pivot.x - offset.x) / scale + val sy = (screen.y - pivot.y - offset.y) / scale + // R(-rotation) = transpose of R(rotation) + return Offset( + x = pivot.x + sx * cosR + sy * sinR, + y = pivot.y - sx * sinR + sy * cosR + ) +} + +private fun projectRectCornersToContentBounds( + rect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + if (rect.isEmpty || scale <= 0f) return Rect.Zero + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + val corners = arrayOf( + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft + ) + + var minX = Float.POSITIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + + for (corner in corners) { + val contentPoint = screenToContent(corner, scale, cosR, sinR, offset, pivot) + minX = min(minX, contentPoint.x) + minY = min(minY, contentPoint.y) + maxX = max(maxX, contentPoint.x) + maxY = max(maxY, contentPoint.y) + } + + return Rect(minX, minY, maxX, maxY) +} + +private fun rectCorners(rect: Rect): Array = arrayOf( + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft +) + +private fun Rect.containsWithTolerance(point: Offset, epsilon: Float = GeometryEpsilon): Boolean { + return point.x >= left - epsilon && point.x <= right + epsilon && + point.y >= top - epsilon && point.y <= bottom + epsilon +} + +private fun lerpRect(start: Rect, end: Rect, fraction: Float): Rect { + return Rect( + left = start.left + (end.left - start.left) * fraction, + top = start.top + (end.top - start.top) * fraction, + right = start.right + (end.right - start.right) * fraction, + bottom = start.bottom + (end.bottom - start.bottom) * fraction + ) +} + +fun calculateScalarTransformedBounds( + baseBounds: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + val corners = arrayOf( + baseBounds.topLeft, baseBounds.topRight, + baseBounds.bottomRight, baseBounds.bottomLeft + ) + + var minX = Float.POSITIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + + for (p in corners) { + val s = contentToScreen(p, scale, cosR, sinR, offset, pivot) + minX = min(minX, s.x); minY = min(minY, s.y) + maxX = max(maxX, s.x); maxY = max(maxY, s.y) + } + return Rect(minX, minY, maxX, maxY) +} + +internal fun isCropRectCoveredByImage( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Boolean { + if (baseBounds.isEmpty || cropRect.isEmpty || scale <= 0f) return false + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + return rectCorners(cropRect).all { corner -> + baseBounds.containsWithTolerance( + point = screenToContent( + screen = corner, + scale = scale, + cosR = cosR, + sinR = sinR, + offset = offset, + pivot = pivot + ) + ) + } +} + +internal fun constrainCropRectToImage( + currentCropRect: Rect, + candidateRect: Rect, + visibleBounds: Rect, + minCropSizePx: Float, + baseBounds: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + val constrainedCandidate = constrainCropRect( + cropRect = candidateRect, + bounds = visibleBounds, + minCropSizePx = minCropSizePx + ) + + if (baseBounds.isEmpty || constrainedCandidate.isEmpty || scale <= 0f) return constrainedCandidate + if (isCropRectCoveredByImage(baseBounds, constrainedCandidate, scale, rotationDegrees, offset, pivot)) { + return constrainedCandidate + } + if (!isCropRectCoveredByImage(baseBounds, currentCropRect, scale, rotationDegrees, offset, pivot)) { + return currentCropRect + } + + var low = 0f + var high = 1f + repeat(20) { + val mid = (low + high) / 2f + val interpolated = lerpRect(currentCropRect, constrainedCandidate, mid) + if (isCropRectCoveredByImage(baseBounds, interpolated, scale, rotationDegrees, offset, pivot)) { + low = mid + } else { + high = mid + } + } + + return lerpRect(currentCropRect, constrainedCandidate, low) +} + +fun offsetForZoomAroundAnchor( + currentOffset: Offset, + pivot: Offset, + anchor: Offset, + zoom: Float +): Offset { + return Offset( + x = (1f - zoom) * (anchor.x - pivot.x) + zoom * currentOffset.x, + y = (1f - zoom) * (anchor.y - pivot.y) + zoom * currentOffset.y + ) +} + +fun offsetForRotationAroundAnchor( + currentOffset: Offset, + pivot: Offset, + anchor: Offset, + deltaAngleDegrees: Float +): Offset { + val rad = Math.toRadians(deltaAngleDegrees.toDouble()) + val cosD = cos(rad).toFloat() + val sinD = sin(rad).toFloat() + + val vx = anchor.x - pivot.x - currentOffset.x + val vy = anchor.y - pivot.y - currentOffset.y + val rvx = vx * cosD - vy * sinD + val rvy = vx * sinD + vy * cosD + + return Offset( + x = anchor.x - pivot.x - rvx, + y = anchor.y - pivot.y - rvy + ) +} + +fun clampOffsetToCoverCrop( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Offset { + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = scale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return offset + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + var cdx = 0f + var cdy = 0f + + if (cropContentBounds.left < baseBounds.left) { + cdx = baseBounds.left - cropContentBounds.left + } else if (cropContentBounds.right > baseBounds.right) { + cdx = baseBounds.right - cropContentBounds.right + } + + if (cropContentBounds.top < baseBounds.top) { + cdy = baseBounds.top - cropContentBounds.top + } else if (cropContentBounds.bottom > baseBounds.bottom) { + cdy = baseBounds.bottom - cropContentBounds.bottom + } + + if (cdx == 0f && cdy == 0f) return offset + + val screenDx = -scale * (cdx * cosR - cdy * sinR) + val screenDy = -scale * (cdx * sinR + cdy * cosR) + + return Offset(offset.x + screenDx, offset.y + screenDy) +} + +fun fitContentInBounds( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset, + minScale: Float = 0.5f, + maxScale: Float = 30f +): Pair { + if (baseBounds.isEmpty || cropRect.isEmpty) return Pair(scale, offset) + + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = scale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return Pair(scale, offset) + + val cropContentW = cropContentBounds.width + val cropContentH = cropContentBounds.height + var newScale = scale + + if (cropContentW > baseBounds.width || cropContentH > baseBounds.height) { + val scaleX = if (baseBounds.width > 0f) cropContentW / baseBounds.width else 1f + val scaleY = if (baseBounds.height > 0f) cropContentH / baseBounds.height else 1f + val correction = max(scaleX, scaleY) + newScale = (scale * correction).coerceIn(minScale, maxScale) + } + + val newOffset = if (newScale != scale) { + val zoomFactor = newScale / scale + offsetForZoomAroundAnchor(offset, pivot, cropRect.center, zoomFactor) + } else { + offset + } + + val clampedOffset = clampOffsetToCoverCrop(baseBounds, cropRect, newScale, rotationDegrees, newOffset, pivot) + return Pair(newScale, clampedOffset) +} + +fun minimumScaleToCoverCrop( + baseBounds: Rect, + cropRect: Rect, + currentScale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Float { + if (baseBounds.isEmpty || cropRect.isEmpty || currentScale <= 0f) return currentScale + + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = currentScale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return currentScale + + val contentSpanX = cropContentBounds.width + val contentSpanY = cropContentBounds.height + + var minScale = 0f + if (baseBounds.width > 0f && contentSpanX > 0f) { + minScale = max(minScale, currentScale * contentSpanX / baseBounds.width) + } + if (baseBounds.height > 0f && contentSpanY > 0f) { + minScale = max(minScale, currentScale * contentSpanY / baseBounds.height) + } + return minScale +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt new file mode 100644 index 00000000..1a884fd2 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt @@ -0,0 +1,257 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +fun calculateCropRect(bounds: IntSize, imageSize: IntSize): Rect { + if (bounds.width <= 0 || bounds.height <= 0 || imageSize.width <= 0 || imageSize.height <= 0) { + return Rect.Zero + } + val imageAspect = imageSize.width.toFloat() / imageSize.height.toFloat() + val canvasAspect = bounds.width.toFloat() / bounds.height.toFloat() + return if (imageAspect > canvasAspect) { + val fittedHeight = bounds.width / imageAspect + val top = (bounds.height - fittedHeight) / 2f + Rect(0f, top, bounds.width.toFloat(), top + fittedHeight) + } else { + val fittedWidth = bounds.height * imageAspect + val left = (bounds.width - fittedWidth) / 2f + Rect(left, 0f, left + fittedWidth, bounds.height.toFloat()) + } +} + +fun constrainCropRect(cropRect: Rect, bounds: Rect, minCropSizePx: Float): Rect { + val b = Rect( + left = minOf(bounds.left, bounds.right), + top = minOf(bounds.top, bounds.bottom), + right = maxOf(bounds.left, bounds.right), + bottom = maxOf(bounds.top, bounds.bottom) + ) + if (b.width <= 0f || b.height <= 0f) return cropRect + val minW = minCropSizePx.coerceAtMost(b.width) + val minH = minCropSizePx.coerceAtMost(b.height) + val w = cropRect.width.coerceIn(minW, b.width) + val h = cropRect.height.coerceIn(minH, b.height) + val l = cropRect.left.coerceIn(b.left, (b.right - w).coerceAtLeast(b.left)) + val t = cropRect.top.coerceIn(b.top, (b.bottom - h).coerceAtLeast(b.top)) + return Rect(l, t, l + w, t + h) +} + + + +private enum class CropHandle { + NONE, MOVE, + TOP_LEFT, TOP, TOP_RIGHT, RIGHT, + BOTTOM_RIGHT, BOTTOM, BOTTOM_LEFT, LEFT +} + + + +@Composable +fun CropScrim(cropRect: Rect, modifier: Modifier = Modifier) { + Canvas( + modifier = modifier + .fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + ) { + drawRect(Color.Black.copy(alpha = 0.7f), size = size) + drawRect(Color.Transparent, topLeft = cropRect.topLeft, size = cropRect.size, blendMode = BlendMode.Clear) + } +} + + + +@Composable +fun CropOverlay( + cropRect: Rect, + bounds: Rect, + minCropSizePx: Float, + onCropRectChange: (Rect) -> Unit, + onContentTransform: (centroid: Offset, pan: Offset, zoom: Float) -> Unit = { _, _, _ -> }, + onResizeEnded: () -> Unit = {}, + onDragStateChange: (Boolean) -> Unit = {}, + modifier: Modifier = Modifier +) { + val currentCropRect by rememberUpdatedState(cropRect) + val currentOnCropRectChange by rememberUpdatedState(onCropRectChange) + val currentOnContentTransform by rememberUpdatedState(onContentTransform) + val currentOnResizeEnded by rememberUpdatedState(onResizeEnded) + val currentOnDragStateChange by rememberUpdatedState(onDragStateChange) + val handleTouchRadiusPx = 28.dp + val cornerHandleZonePx = 44.dp + val sideHandleLengthPx = 36.dp + val sideTouchInsetPx = 24.dp + + + var isResizing by remember { mutableStateOf(false) } + + Canvas( + modifier = modifier + .fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + + .pointerInput(bounds, minCropSizePx) { + val handleTouchRadius = handleTouchRadiusPx.toPx() + val cornerHandleZone = cornerHandleZonePx.toPx() + val sideTouchInset = sideTouchInsetPx.toPx() + + awaitEachGesture { + val down = awaitFirstDown(pass = PointerEventPass.Initial) + val activeHandle = pickCropHandle( + down.position, currentCropRect, + handleTouchRadius, cornerHandleZone, sideTouchInset + ) + + if (activeHandle == CropHandle.NONE || activeHandle == CropHandle.MOVE) { + return@awaitEachGesture + } + + var dragRect = currentCropRect + down.consume() + isResizing = true + currentOnDragStateChange(true) + + try { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val primary = event.changes.firstOrNull { it.id == down.id } + ?: event.changes.firstOrNull { it.pressed } + if (primary == null || !primary.pressed) break + + val drag = primary.position - primary.previousPosition + if (drag == Offset.Zero) continue + primary.consume() + + dragRect = resizeCropRect(dragRect, activeHandle, drag, bounds, minCropSizePx) + currentOnCropRectChange(dragRect) + } + } finally { + isResizing = false + currentOnResizeEnded() + currentOnDragStateChange(false) + } + } + } + + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, zoom, _ -> + if (!isResizing) { + currentOnContentTransform(centroid, pan, zoom) + } + } + } + ) { + + val strokeWidth = 1.dp.toPx() + val gridColor = Color.White.copy(alpha = 0.5f) + drawLine(gridColor, Offset(cropRect.left + cropRect.width / 3, cropRect.top), Offset(cropRect.left + cropRect.width / 3, cropRect.bottom), strokeWidth) + drawLine(gridColor, Offset(cropRect.left + cropRect.width * 2 / 3, cropRect.top), Offset(cropRect.left + cropRect.width * 2 / 3, cropRect.bottom), strokeWidth) + drawLine(gridColor, Offset(cropRect.left, cropRect.top + cropRect.height / 3), Offset(cropRect.right, cropRect.top + cropRect.height / 3), strokeWidth) + drawLine(gridColor, Offset(cropRect.left, cropRect.top + cropRect.height * 2 / 3), Offset(cropRect.right, cropRect.top + cropRect.height * 2 / 3), strokeWidth) + + + val cornerLen = 24.dp.toPx() + val cornerStroke = 3.dp.toPx() + val white = Color.White + drawLine(white, cropRect.topLeft, cropRect.topLeft.copy(x = cropRect.left + cornerLen), cornerStroke) + drawLine(white, cropRect.topLeft, cropRect.topLeft.copy(y = cropRect.top + cornerLen), cornerStroke) + drawLine(white, cropRect.topRight, cropRect.topRight.copy(x = cropRect.right - cornerLen), cornerStroke) + drawLine(white, cropRect.topRight, cropRect.topRight.copy(y = cropRect.top + cornerLen), cornerStroke) + drawLine(white, cropRect.bottomLeft, cropRect.bottomLeft.copy(x = cropRect.left + cornerLen), cornerStroke) + drawLine(white, cropRect.bottomLeft, cropRect.bottomLeft.copy(y = cropRect.bottom - cornerLen), cornerStroke) + drawLine(white, cropRect.bottomRight, cropRect.bottomRight.copy(x = cropRect.right - cornerLen), cornerStroke) + drawLine(white, cropRect.bottomRight, cropRect.bottomRight.copy(y = cropRect.bottom - cornerLen), cornerStroke) + + + val sideLen = sideHandleLengthPx.toPx() + val sideStroke = 4.dp.toPx() + val cx = cropRect.left + cropRect.width / 2f + val cy = cropRect.top + cropRect.height / 2f + val half = sideLen / 2f + drawLine(white, Offset(cx - half, cropRect.top), Offset(cx + half, cropRect.top), sideStroke) + drawLine(white, Offset(cx - half, cropRect.bottom), Offset(cx + half, cropRect.bottom), sideStroke) + drawLine(white, Offset(cropRect.left, cy - half), Offset(cropRect.left, cy + half), sideStroke) + drawLine(white, Offset(cropRect.right, cy - half), Offset(cropRect.right, cy + half), sideStroke) + + + drawRect(Color.White.copy(alpha = 0.8f), cropRect.topLeft, cropRect.size, style = Stroke(1.dp.toPx())) + } +} + + + +private fun pickCropHandle( + point: Offset, crop: Rect, + touchRadius: Float, cornerZone: Float, sideInset: Float +): CropHandle { + val topBand = (crop.top - sideInset)..(crop.top + sideInset) + val bottomBand = (crop.bottom - sideInset)..(crop.bottom + sideInset) + val leftBand = (crop.left - sideInset)..(crop.left + sideInset) + val rightBand = (crop.right - sideInset)..(crop.right + sideInset) + val inH = point.x in crop.left..crop.right + val inV = point.y in crop.top..crop.bottom + return when { + inCorner(point, crop, CropHandle.TOP_LEFT, touchRadius, cornerZone) -> CropHandle.TOP_LEFT + inCorner(point, crop, CropHandle.TOP_RIGHT, touchRadius, cornerZone) -> CropHandle.TOP_RIGHT + inCorner(point, crop, CropHandle.BOTTOM_RIGHT, touchRadius, cornerZone) -> CropHandle.BOTTOM_RIGHT + inCorner(point, crop, CropHandle.BOTTOM_LEFT, touchRadius, cornerZone) -> CropHandle.BOTTOM_LEFT + inH && point.y in topBand -> CropHandle.TOP + inV && point.x in rightBand -> CropHandle.RIGHT + inH && point.y in bottomBand -> CropHandle.BOTTOM + inV && point.x in leftBand -> CropHandle.LEFT + inH && inV -> CropHandle.MOVE + else -> CropHandle.NONE + } +} + +private fun resizeCropRect(crop: Rect, handle: CropHandle, drag: Offset, bounds: Rect, minSize: Float): Rect { + if (bounds.width <= 0f || bounds.height <= 0f) return crop + val minW = minSize.coerceAtMost(bounds.width) + val minH = minSize.coerceAtMost(bounds.height) + var l = crop.left; var t = crop.top; var r = crop.right; var b = crop.bottom + when (handle) { + CropHandle.MOVE -> { /* not used here */ } + CropHandle.TOP_LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW); t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.TOP -> { t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.TOP_RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right); t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right) } + CropHandle.BOTTOM_RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right); b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.BOTTOM -> { b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.BOTTOM_LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW); b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW) } + CropHandle.NONE -> {} + } + return Rect(l, t, r, b) +} + +private fun inCorner(point: Offset, crop: Rect, handle: CropHandle, radius: Float, zone: Float): Boolean { + val r = when (handle) { + CropHandle.TOP_LEFT -> Rect(crop.left - radius, crop.top - radius, crop.left + zone, crop.top + zone) + CropHandle.TOP_RIGHT -> Rect(crop.right - zone, crop.top - radius, crop.right + radius, crop.top + zone) + CropHandle.BOTTOM_RIGHT -> Rect(crop.right - zone, crop.bottom - zone, crop.right + radius, crop.bottom + radius) + CropHandle.BOTTOM_LEFT -> Rect(crop.left - radius, crop.bottom - zone, crop.left + zone, crop.bottom + radius) + else -> return false + } + return point.x in r.left..r.right && point.y in r.top..r.bottom +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt new file mode 100644 index 00000000..954317e3 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt @@ -0,0 +1,125 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CropGeometryTest { + @Test + fun `fitContentInBounds restores crop coverage for rotated image`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val cropRect = Rect(left = 15f, top = 15f, right = 85f, bottom = 85f) + val initialScale = 1.2f + val initialOffset = Offset(50f, -40f) + + assertFalse( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = initialScale, + rotationDegrees = 35f, + offset = initialOffset, + pivot = baseBounds.center + ) + ) + + val (newScale, newOffset) = fitContentInBounds( + baseBounds = baseBounds, + cropRect = cropRect, + scale = initialScale, + rotationDegrees = 35f, + offset = initialOffset, + pivot = baseBounds.center, + minScale = 0.5f, + maxScale = 10f + ) + + assertTrue( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = newScale, + rotationDegrees = 35f, + offset = newOffset, + pivot = baseBounds.center + ) + ) + } + + @Test + fun `isCropRectCoveredByImage rejects crop in rotated bounding box corner`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val visibleBounds = rotatedVisibleBounds(baseBounds) + val cropRect = Rect( + left = visibleBounds.left, + top = visibleBounds.top, + right = 65f, + bottom = 65f + ) + + assertFalse( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + ) + } + + @Test + fun `constrainCropRectToImage pulls invalid rotated crop back inside image`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val visibleBounds = rotatedVisibleBounds(baseBounds) + val currentCropRect = Rect(left = 35f, top = 35f, right = 65f, bottom = 65f) + val candidateRect = Rect( + left = visibleBounds.left, + top = visibleBounds.top, + right = currentCropRect.right, + bottom = currentCropRect.bottom + ) + + val constrained = constrainCropRectToImage( + currentCropRect = currentCropRect, + candidateRect = candidateRect, + visibleBounds = visibleBounds, + minCropSizePx = 16f, + baseBounds = baseBounds, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + + assertTrue(constrained.left > candidateRect.left + EPSILON) + assertTrue(constrained.top > candidateRect.top + EPSILON) + assertTrue( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = constrained, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + ) + } + + private fun rotatedVisibleBounds(baseBounds: Rect): Rect { + return calculateScalarTransformedBounds( + baseBounds = baseBounds, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + } + + private companion object { + const val EPSILON = 0.001f + } +}