feat: add interactive image cropping with bottom sheet UI for shopping list item photos

- Extract `ImageCropBottomSheet` component for reusable crop interface
- Remove inline `saveCroppedImage` helper in favor of centralized cropping flow
- Add tap-to-recrop functionality on existing item photos in detail sheet
- Implement crop state management with `cropBitmap`, `cropForNewItem`, and `cropForItemId` flags
- Pass `onRequestCrop` callback to `ItemDetailSheet` for both camera/gallery and existing
This commit is contained in:
Bruno Charest 2026-04-27 06:45:14 -04:00
parent 48a9266942
commit c8dff8df40
3 changed files with 401 additions and 32 deletions

View File

@ -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
}
}

View File

@ -101,6 +101,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.safebite.app.R import com.safebite.app.R
import com.safebite.app.domain.engine.CatalogProvider 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.common.components.PrimaryButton
import com.safebite.app.presentation.theme.LocalDimens import com.safebite.app.presentation.theme.LocalDimens
import com.safebite.app.presentation.theme.LocalStatusColors import com.safebite.app.presentation.theme.LocalStatusColors
@ -149,12 +150,17 @@ fun ListDetailScreen(
var itemDescription by remember { mutableStateOf("") } var itemDescription by remember { mutableStateOf("") }
val context = LocalContext.current val context = LocalContext.current
var cropBitmap by remember { mutableStateOf<Bitmap?>(null) }
var cropForNewItem by remember { mutableStateOf(false) }
var cropForItemId by remember { mutableStateOf<Long?>(null) }
val takePictureLauncher = rememberLauncherForActivityResult( val takePictureLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.TakePicturePreview() ActivityResultContracts.TakePicturePreview()
) { bitmap: Bitmap? -> ) { bitmap: Bitmap? ->
bitmap?.let { bitmap?.let {
selectedImageUri = saveCroppedImage(context, it) cropBitmap = it
showDescriptionDialog = true cropForNewItem = true
cropForItemId = null
} }
showPhotoPicker = false showPhotoPicker = false
} }
@ -167,8 +173,9 @@ fun ListDetailScreen(
BitmapFactory.decodeStream(stream) BitmapFactory.decodeStream(stream)
} }
bmp?.let { bitmap -> bmp?.let { bitmap ->
selectedImageUri = saveCroppedImage(context, bitmap) cropBitmap = bitmap
showDescriptionDialog = true cropForNewItem = true
cropForItemId = null
} }
} }
showPhotoPicker = false showPhotoPicker = false
@ -316,7 +323,35 @@ fun ListDetailScreen(
onUpdateImage = { imageUrl -> viewModel.updateItemImageUrl(selected.id, imageUrl) }, onUpdateImage = { imageUrl -> viewModel.updateItemImageUrl(selected.id, imageUrl) },
onMoveTo = { targetListId -> viewModel.moveItemToList(selected.id, targetListId) }, onMoveTo = { targetListId -> viewModel.moveItemToList(selected.id, targetListId) },
onDelete = { viewModel.deleteItem(selected.id) }, 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 // Contenu principal scrollable
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -942,7 +957,8 @@ private fun ItemDetailSheet(
onUpdateImage: (String?) -> Unit, onUpdateImage: (String?) -> Unit,
onMoveTo: (Long) -> Unit, onMoveTo: (Long) -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onOpenProduct: (() -> Unit)? onOpenProduct: (() -> Unit)?,
onRequestCrop: (Bitmap) -> Unit
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val context = LocalContext.current val context = LocalContext.current
@ -960,9 +976,7 @@ private fun ItemDetailSheet(
val bmp = context.contentResolver.openInputStream(it)?.use { stream -> val bmp = context.contentResolver.openInputStream(it)?.use { stream ->
BitmapFactory.decodeStream(stream) BitmapFactory.decodeStream(stream)
} }
bmp?.let { bitmap -> bmp?.let { bitmap -> onRequestCrop(bitmap) }
onUpdateImage(saveCroppedImage(context, bitmap))
}
} }
} }
@ -1015,7 +1029,17 @@ private fun ItemDetailSheet(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp) .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 contentScale = ContentScale.Crop
) )
} }

View File

@ -1,4 +1,4 @@
MAJOR=1 MAJOR=1
MINOR=9 MINOR=9
PATCH=0 PATCH=2
CODE=11 CODE=13