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:
parent
48a9266942
commit
c8dff8df40
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<Bitmap?>(null) }
|
||||
var cropForNewItem by remember { mutableStateOf(false) }
|
||||
var cropForItemId by remember { mutableStateOf<Long?>(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
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
MAJOR=1
|
||||
MINOR=9
|
||||
PATCH=0
|
||||
CODE=11
|
||||
PATCH=2
|
||||
CODE=13
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user