diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/ImageCropBottomSheet.kt b/app/src/main/java/com/safebite/app/presentation/common/components/ImageCropBottomSheet.kt index 4722e69..1fc565b 100644 --- a/app/src/main/java/com/safebite/app/presentation/common/components/ImageCropBottomSheet.kt +++ b/app/src/main/java/com/safebite/app/presentation/common/components/ImageCropBottomSheet.kt @@ -4,7 +4,7 @@ import android.graphics.Bitmap import android.net.Uri import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image -import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,9 +13,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -31,26 +31,30 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import java.io.File import kotlin.math.max import kotlin.math.min +private val HandleSize = 28.dp +private val MinFrameSize = 48.dp + /** - * Bottom sheet permettant de recadrer une image avec zoom/pan. - * Affiche un cadre blanc centré (type scanner) et un overlay sombre. + * Bottom sheet permettant de recadrer une image. + * La photo reste fixe en arrière-plan. L'utilisateur déplace et redimensionne + * le cadre de recadrage (overlay sombre + cadre blanc avec poignées aux coins). */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -61,59 +65,42 @@ fun ImageCropBottomSheet( sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) { val context = LocalContext.current + val density = LocalDensity.current var containerSize by remember { mutableStateOf(IntSize.Zero) } - var scale by remember { mutableFloatStateOf(1f) } - var offsetX by remember { mutableFloatStateOf(0f) } - var offsetY by remember { mutableFloatStateOf(0f) } - - val bmpW = bitmap.width.toFloat() - val bmpH = bitmap.height.toFloat() - val containerW = containerSize.width.toFloat().coerceAtLeast(1f) val containerH = containerSize.height.toFloat().coerceAtLeast(1f) - val fitScale = remember(containerW, containerH, bmpW, bmpH) { - min(containerW / bmpW, containerH / bmpH) - } + val bmpW = bitmap.width.toFloat() + val bmpH = bitmap.height.toFloat() + val bmpRatio = bmpW / bmpH + val containerRatio = containerW / containerH.coerceAtLeast(1f) - val initialOffsetX = (containerW - bmpW * fitScale) / 2f - val initialOffsetY = (containerH - bmpH * fitScale) / 2f + val displayedW: Float + val displayedH: Float + val displayedOffsetX: Float + val displayedOffsetY: Float - val totalScale = fitScale * scale - val displayW = bmpW * totalScale - val displayH = bmpH * totalScale - - val frameSize = min(containerW, containerH) * 0.8f - val frameLeft = (containerW - frameSize) / 2f - val frameTop = (containerH - frameSize) / 2f - val frameRight = frameLeft + frameSize - val frameBottom = frameTop + frameSize - - // Contraintes de pan pour garder le cadre rempli - val minOffsetX = if (displayW > frameSize) { - frameLeft - initialOffsetX - displayW + if (bmpRatio > containerRatio) { + displayedW = containerW + displayedH = containerW / bmpRatio + displayedOffsetX = 0f + displayedOffsetY = (containerH - displayedH) / 2f } else { - (containerW - displayW) / 2f - initialOffsetX - } - val maxOffsetX = if (displayW > frameSize) { - frameRight - initialOffsetX - } else { - (containerW - displayW) / 2f - initialOffsetX - } - val minOffsetY = if (displayH > frameSize) { - frameTop - initialOffsetY - displayH - } else { - (containerH - displayH) / 2f - initialOffsetY - } - val maxOffsetY = if (displayH > frameSize) { - frameBottom - initialOffsetY - } else { - (containerH - displayH) / 2f - initialOffsetY + displayedH = containerH + displayedW = containerH * bmpRatio + displayedOffsetX = (containerW - displayedW) / 2f + displayedOffsetY = 0f } - val totalOffsetX = initialOffsetX + offsetX - val totalOffsetY = initialOffsetY + offsetY + val scaleToBmp = displayedW / bmpW + val halfHandlePx = with(density) { (HandleSize / 2).toPx() } + val minPx = with(density) { MinFrameSize.toPx() } + + var frameLeft by remember { mutableFloatStateOf(containerW * 0.15f) } + var frameTop by remember { mutableFloatStateOf(containerH * 0.15f) } + var frameRight by remember { mutableFloatStateOf(containerW * 0.85f) } + var frameBottom by remember { mutableFloatStateOf(containerH * 0.85f) } ModalBottomSheet( onDismissRequest = onDismiss, @@ -123,13 +110,13 @@ fun ImageCropBottomSheet( Column( modifier = Modifier .fillMaxWidth() - .height(520.dp) + .height(560.dp) .padding(horizontal = 16.dp, vertical = 12.dp) ) { Text( text = "Ajuster le cadrage", style = MaterialTheme.typography.titleLarge, - fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 12.dp) ) @@ -138,135 +125,123 @@ fun ImageCropBottomSheet( .weight(1f) .fillMaxWidth() .onSizeChanged { containerSize = it } - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - val newScale = (scale * zoom).coerceIn(1f, 5f) - val scaleChange = newScale / scale - scale = newScale - - // Ajuste l'offset pour zoomer autour du centre du cadre - val focusX = containerW / 2f - val focusY = containerH / 2f - val relFocusX = focusX - totalOffsetX - val relFocusY = focusY - totalOffsetY - - offsetX = (offsetX + pan.x - relFocusX * (scaleChange - 1f)) - .coerceIn(minOffsetX, maxOffsetX) - offsetY = (offsetY + pan.y - relFocusY * (scaleChange - 1f)) - .coerceIn(minOffsetY, maxOffsetY) - } - } ) { - // Image zoomable/pannable + // Photo fixe en arrière-plan (ContentScale.Fit) Image( bitmap = bitmap.asImageBitmap(), contentDescription = null, - contentScale = ContentScale.None, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - this.scaleX = totalScale - this.scaleY = totalScale - this.translationX = totalOffsetX - this.translationY = totalOffsetY - transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0f) - } + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() ) // Overlay sombre + cadre blanc Canvas(modifier = Modifier.fillMaxSize()) { - val fSize = min(size.width, size.height) * 0.8f - val fLeft = (size.width - fSize) / 2f - val fTop = (size.height - fSize) / 2f - val fRight = fLeft + fSize - val fBottom = fTop + fSize + val fL = frameLeft + val fT = frameTop + val fR = frameRight + val fB = frameBottom - // Top + // Zone sombre drawRect( color = Color.Black.copy(alpha = 0.5f), topLeft = Offset(0f, 0f), - size = Size(size.width, fTop) + size = Size(size.width, fT) ) - // Bottom drawRect( color = Color.Black.copy(alpha = 0.5f), - topLeft = Offset(0f, fBottom), - size = Size(size.width, size.height - fBottom) + topLeft = Offset(0f, fB), + size = Size(size.width, size.height - fB) ) - // Left drawRect( color = Color.Black.copy(alpha = 0.5f), - topLeft = Offset(0f, fTop), - size = Size(fLeft, fSize) + topLeft = Offset(0f, fT), + size = Size(fL, fB - fT) ) - // Right drawRect( color = Color.Black.copy(alpha = 0.5f), - topLeft = Offset(fRight, fTop), - size = Size(size.width - fRight, fSize) + topLeft = Offset(fR, fT), + size = Size(size.width - fR, fB - fT) ) - // Coins du cadre (type scanner) - val cornerLength = fSize * 0.15f - val strokeWidth = 3f - val cornerColor = Color.White - - // Top-left - drawLine( - cornerColor, - Offset(fLeft, fTop), - Offset(fLeft + cornerLength, fTop), - strokeWidth = strokeWidth - ) - drawLine( - cornerColor, - Offset(fLeft, fTop), - Offset(fLeft, fTop + cornerLength), - strokeWidth = strokeWidth - ) - - // Top-right - drawLine( - cornerColor, - Offset(fRight, fTop), - Offset(fRight - cornerLength, fTop), - strokeWidth = strokeWidth - ) - drawLine( - cornerColor, - Offset(fRight, fTop), - Offset(fRight, fTop + cornerLength), - strokeWidth = strokeWidth - ) - - // Bottom-left - drawLine( - cornerColor, - Offset(fLeft, fBottom), - Offset(fLeft + cornerLength, fBottom), - strokeWidth = strokeWidth - ) - drawLine( - cornerColor, - Offset(fLeft, fBottom), - Offset(fLeft, fBottom - cornerLength), - strokeWidth = strokeWidth - ) - - // Bottom-right - drawLine( - cornerColor, - Offset(fRight, fBottom), - Offset(fRight - cornerLength, fBottom), - strokeWidth = strokeWidth - ) - drawLine( - cornerColor, - Offset(fRight, fBottom), - Offset(fRight, fBottom - cornerLength), - strokeWidth = strokeWidth + // Bordure blanche + drawRect( + color = Color.White, + topLeft = Offset(fL, fT), + size = Size(fR - fL, fB - fT), + style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f) ) } + + // Zone centrale draggable pour déplacer le cadre entier + Box( + modifier = Modifier + .offset( + x = with(density) { frameLeft.toDp() }, + y = with(density) { frameTop.toDp() } + ) + .size( + width = with(density) { (frameRight - frameLeft).toDp() }, + height = with(density) { (frameBottom - frameTop).toDp() } + ) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + val dx = dragAmount.x + val dy = dragAmount.y + val w = frameRight - frameLeft + val h = frameBottom - frameTop + frameLeft = (frameLeft + dx).coerceIn(0f, containerW - w) + frameTop = (frameTop + dy).coerceIn(0f, containerH - h) + frameRight = frameLeft + w + frameBottom = frameTop + h + } + } + .zIndex(1f) + ) + + // Poignée coin haut-gauche + FrameHandle( + x = frameLeft, + y = frameTop, + halfHandlePx = halfHandlePx, + onDrag = { dx, dy -> + frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f) + frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f) + } + ) + + // Poignée coin haut-droit + FrameHandle( + x = frameRight, + y = frameTop, + halfHandlePx = halfHandlePx, + onDrag = { dx, dy -> + frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW) + frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f) + } + ) + + // Poignée coin bas-gauche + FrameHandle( + x = frameLeft, + y = frameBottom, + halfHandlePx = halfHandlePx, + onDrag = { dx, dy -> + frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f) + frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH) + } + ) + + // Poignée coin bas-droit + FrameHandle( + x = frameRight, + y = frameBottom, + halfHandlePx = halfHandlePx, + onDrag = { dx, dy -> + frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW) + frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH) + } + ) } Spacer(modifier = Modifier.height(12.dp)) @@ -286,14 +261,31 @@ fun ImageCropBottomSheet( PrimaryButton( text = "OK", onClick = { - val cropped = cropAndSaveBitmap( - bitmap, - containerW, containerH, - frameLeft, frameTop, frameSize, - totalOffsetX, totalOffsetY, totalScale, - context.cacheDir - ) - onCropComplete(cropped) + val cropLeft = ((frameLeft - displayedOffsetX) / scaleToBmp).toInt().coerceIn(0, bitmap.width) + val cropTop = ((frameTop - displayedOffsetY) / scaleToBmp).toInt().coerceIn(0, bitmap.height) + val cropRight = ((frameRight - displayedOffsetX) / scaleToBmp).toInt().coerceIn(cropLeft + 1, bitmap.width) + val cropBottom = ((frameBottom - displayedOffsetY) / scaleToBmp).toInt().coerceIn(cropTop + 1, bitmap.height) + + val cropW = cropRight - cropLeft + val cropH = cropBottom - cropTop + + val cropped = Bitmap.createBitmap(bitmap, cropLeft, cropTop, cropW, cropH) + val maxSize = 512 + val out = if (cropW > maxSize || cropH > maxSize) { + val ratio = maxSize.toFloat() / max(cropW, cropH) + val newW = (cropW * ratio).toInt() + val newH = (cropH * ratio).toInt() + Bitmap.createScaledBitmap(cropped, newW, newH, true).also { + if (it != cropped) cropped.recycle() + } + } else cropped + + val file = File(context.cacheDir, "item_${System.currentTimeMillis()}.jpg") + file.outputStream().use { fos -> + out.compress(Bitmap.CompressFormat.JPEG, 85, fos) + } + if (out != cropped && out != bitmap) out.recycle() + onCropComplete(Uri.fromFile(file).toString()) }, modifier = Modifier.weight(1f), large = true @@ -304,42 +296,47 @@ fun ImageCropBottomSheet( } } -private fun cropAndSaveBitmap( - bitmap: Bitmap, - containerW: Float, - containerH: Float, - frameLeft: Float, - frameTop: Float, - frameSize: Float, - totalOffsetX: Float, - totalOffsetY: Float, - totalScale: Float, - cacheDir: File -): String? { - return try { - val cropX = ((frameLeft - totalOffsetX) / totalScale).toInt().coerceIn(0, bitmap.width) - val cropY = ((frameTop - totalOffsetY) / totalScale).toInt().coerceIn(0, bitmap.height) - val cropW = (frameSize / totalScale).toInt().coerceIn(1, bitmap.width - cropX) - val cropH = (frameSize / totalScale).toInt().coerceIn(1, bitmap.height - cropY) - - val cropped = Bitmap.createBitmap(bitmap, cropX, cropY, cropW, cropH) - val maxSize = 512 - val scaled = if (cropW > maxSize || cropH > maxSize) { - val ratio = maxSize.toFloat() / maxOf(cropW, cropH) - val newW = (cropW * ratio).toInt() - val newH = (cropH * ratio).toInt() - Bitmap.createScaledBitmap(cropped, newW, newH, true).also { - if (it != cropped) cropped.recycle() +@Composable +private fun FrameHandle( + x: Float, + y: Float, + halfHandlePx: Float, + onDrag: (dx: Float, dy: Float) -> Unit +) { + val density = LocalDensity.current + Box( + modifier = Modifier + .offset( + x = with(density) { (x - halfHandlePx).toDp() }, + y = with(density) { (y - halfHandlePx).toDp() } + ) + .size(HandleSize) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + onDrag(dragAmount.x, dragAmount.y) + } + } + .zIndex(2f), + contentAlignment = Alignment.Center + ) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .size(12.dp) + .zIndex(2f) + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle( + color = Color.White, + radius = size.minDimension / 2f, + center = Offset(size.width / 2f, size.height / 2f) + ) + drawCircle( + color = Color(0xFF1976D2), + radius = size.minDimension / 2f - 2f, + center = Offset(size.width / 2f, size.height / 2f) + ) } - } else cropped - - val file = File(cacheDir, "item_${System.currentTimeMillis()}.jpg") - file.outputStream().use { out -> - scaled.compress(Bitmap.CompressFormat.JPEG, 85, out) } - if (scaled != cropped && scaled != bitmap) scaled.recycle() - Uri.fromFile(file).toString() - } catch (_: Exception) { - null } }