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 new file mode 100644 index 0000000..4722e69 --- /dev/null +++ b/app/src/main/java/com/safebite/app/presentation/common/components/ImageCropBottomSheet.kt @@ -0,0 +1,345 @@ +package com.safebite.app.presentation.common.components + +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.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +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.unit.IntSize +import androidx.compose.ui.unit.dp +import java.io.File +import kotlin.math.max +import kotlin.math.min + +/** + * Bottom sheet permettant de recadrer une image avec zoom/pan. + * Affiche un cadre blanc centré (type scanner) et un overlay sombre. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImageCropBottomSheet( + bitmap: Bitmap, + onCropComplete: (String?) -> Unit, + onDismiss: () -> Unit, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) +) { + val context = LocalContext.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 initialOffsetX = (containerW - bmpW * fitScale) / 2f + val initialOffsetY = (containerH - bmpH * fitScale) / 2f + + 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 + } 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 + } + + val totalOffsetX = initialOffsetX + offsetX + val totalOffsetY = initialOffsetY + offsetY + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + dragHandle = null + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .height(520.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, + modifier = Modifier.padding(bottom = 12.dp) + ) + + Box( + modifier = Modifier + .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 + 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) + } + ) + + // 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 + + // Top + drawRect( + color = Color.Black.copy(alpha = 0.5f), + topLeft = Offset(0f, 0f), + size = Size(size.width, fTop) + ) + // Bottom + drawRect( + color = Color.Black.copy(alpha = 0.5f), + topLeft = Offset(0f, fBottom), + size = Size(size.width, size.height - fBottom) + ) + // Left + drawRect( + color = Color.Black.copy(alpha = 0.5f), + topLeft = Offset(0f, fTop), + size = Size(fLeft, fSize) + ) + // Right + drawRect( + color = Color.Black.copy(alpha = 0.5f), + topLeft = Offset(fRight, fTop), + size = Size(size.width - fRight, fSize) + ) + + // 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 + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding(), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onDismiss, + modifier = Modifier.weight(1f) + ) { + Text("Annuler") + } + PrimaryButton( + text = "OK", + onClick = { + val cropped = cropAndSaveBitmap( + bitmap, + containerW, containerH, + frameLeft, frameTop, frameSize, + totalOffsetX, totalOffsetY, totalScale, + context.cacheDir + ) + onCropComplete(cropped) + }, + modifier = Modifier.weight(1f), + large = true + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +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() + } + } 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 + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt index 232e854..2ffd2b3 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt @@ -101,6 +101,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.safebite.app.R import com.safebite.app.domain.engine.CatalogProvider +import com.safebite.app.presentation.common.components.ImageCropBottomSheet import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.theme.LocalDimens import com.safebite.app.presentation.theme.LocalStatusColors @@ -149,12 +150,17 @@ fun ListDetailScreen( var itemDescription by remember { mutableStateOf("") } val context = LocalContext.current + var cropBitmap by remember { mutableStateOf(null) } + var cropForNewItem by remember { mutableStateOf(false) } + var cropForItemId by remember { mutableStateOf(null) } + val takePictureLauncher = rememberLauncherForActivityResult( ActivityResultContracts.TakePicturePreview() ) { bitmap: Bitmap? -> bitmap?.let { - selectedImageUri = saveCroppedImage(context, it) - showDescriptionDialog = true + cropBitmap = it + cropForNewItem = true + cropForItemId = null } showPhotoPicker = false } @@ -167,8 +173,9 @@ fun ListDetailScreen( BitmapFactory.decodeStream(stream) } bmp?.let { bitmap -> - selectedImageUri = saveCroppedImage(context, bitmap) - showDescriptionDialog = true + cropBitmap = bitmap + cropForNewItem = true + cropForItemId = null } } showPhotoPicker = false @@ -316,7 +323,35 @@ fun ListDetailScreen( onUpdateImage = { imageUrl -> viewModel.updateItemImageUrl(selected.id, imageUrl) }, onMoveTo = { targetListId -> viewModel.moveItemToList(selected.id, targetListId) }, onDelete = { viewModel.deleteItem(selected.id) }, - onOpenProduct = selected.barcode?.let { bc -> { onOpenProduct(bc) } } + onOpenProduct = selected.barcode?.let { bc -> { onOpenProduct(bc) } }, + onRequestCrop = { bitmap -> + cropBitmap = bitmap + cropForNewItem = false + cropForItemId = selected.id + } + ) + } + + // Bottom sheet recadrage image + cropBitmap?.let { bmp -> + ImageCropBottomSheet( + bitmap = bmp, + onCropComplete = { croppedUri -> + cropBitmap = null + if (cropForNewItem) { + selectedImageUri = croppedUri + showDescriptionDialog = true + } else { + cropForItemId?.let { id -> viewModel.updateItemImageUrl(id, croppedUri) } + } + cropForNewItem = false + cropForItemId = null + }, + onDismiss = { + cropBitmap = null + cropForNewItem = false + cropForItemId = null + } ) } @@ -353,26 +388,6 @@ fun ListDetailScreen( } } -/** Croppe au centre en carré, redimensionne à [maxSize] et sauvegarde en JPEG dans le cache. */ -private fun saveCroppedImage(context: android.content.Context, bitmap: Bitmap, maxSize: Int = 512): String? { - return try { - val w = bitmap.width - val h = bitmap.height - val side = kotlin.math.min(w, h) - val x = (w - side) / 2 - val y = (h - side) / 2 - val cropped = Bitmap.createBitmap(bitmap, x, y, side, side) - val scaled = Bitmap.createScaledBitmap(cropped, maxSize, maxSize, true) - val file = File(context.cacheDir, "item_${System.currentTimeMillis()}.jpg") - file.outputStream().use { out -> - scaled.compress(Bitmap.CompressFormat.JPEG, 85, out) - } - Uri.fromFile(file).toString() - } catch (_: Exception) { - null - } -} - // ───────────────────────────────────────────────────────────────────────────── // Contenu principal scrollable // ───────────────────────────────────────────────────────────────────────────── @@ -942,7 +957,8 @@ private fun ItemDetailSheet( onUpdateImage: (String?) -> Unit, onMoveTo: (Long) -> Unit, onDelete: () -> Unit, - onOpenProduct: (() -> Unit)? + onOpenProduct: (() -> Unit)?, + onRequestCrop: (Bitmap) -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val context = LocalContext.current @@ -960,9 +976,7 @@ private fun ItemDetailSheet( val bmp = context.contentResolver.openInputStream(it)?.use { stream -> BitmapFactory.decodeStream(stream) } - bmp?.let { bitmap -> - onUpdateImage(saveCroppedImage(context, bitmap)) - } + bmp?.let { bitmap -> onRequestCrop(bitmap) } } } @@ -1015,7 +1029,17 @@ private fun ItemDetailSheet( modifier = Modifier .fillMaxWidth() .height(180.dp) - .clip(RoundedCornerShape(12.dp)), + .clip(RoundedCornerShape(12.dp)) + .clickable { + val bmp = try { + val uri = Uri.parse(item.imageUrl) + when { + item.imageUrl.startsWith("file://") -> BitmapFactory.decodeFile(uri.path) + else -> context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) } + } + } catch (_: Exception) { null } + bmp?.let { onRequestCrop(it) } + }, contentScale = ContentScale.Crop ) } diff --git a/version.properties b/version.properties index c1ca31b..68f23bd 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ MAJOR=1 MINOR=9 -PATCH=0 -CODE=11 +PATCH=2 +CODE=13