feat: implement center-crop image processing for custom items, enhance onboarding UI with numbered step cards and allergen level selection

- Add `saveCroppedImage` helper to center-crop photos to square, resize to 512px, and compress to JPEG
- Apply cropping to both camera capture and gallery picker in `ListDetailScreen` and `ItemDetailSheet`
- Display custom item photos in tiles with `AsyncImage` and camera badge overlay
- Show full-width photos in item detail sheet
- Redesign onboarding "How it works" step
This commit is contained in:
Bruno Charest 2026-04-26 18:47:11 -04:00
parent 4ac951cf6e
commit 48a9266942
6 changed files with 193 additions and 45 deletions

View File

@ -78,13 +78,16 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import coil.compose.AsyncImage
import java.io.File
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@ -150,11 +153,7 @@ fun ListDetailScreen(
ActivityResultContracts.TakePicturePreview()
) { bitmap: Bitmap? ->
bitmap?.let {
val file = File(context.cacheDir, "sb_photo_${System.currentTimeMillis()}.jpg")
file.outputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
selectedImageUri = Uri.fromFile(file).toString()
selectedImageUri = saveCroppedImage(context, it)
showDescriptionDialog = true
}
showPhotoPicker = false
@ -164,9 +163,14 @@ fun ListDetailScreen(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
selectedImageUri = it.toString()
val bmp = context.contentResolver.openInputStream(it)?.use { stream ->
BitmapFactory.decodeStream(stream)
}
bmp?.let { bitmap ->
selectedImageUri = saveCroppedImage(context, bitmap)
showDescriptionDialog = true
}
}
showPhotoPicker = false
}
@ -349,6 +353,26 @@ 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
// ─────────────────────────────────────────────────────────────────────────────
@ -588,12 +612,25 @@ private fun Tile(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxSize()
) {
if (!data.imageUrl.isNullOrBlank()) {
AsyncImage(
model = data.imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(top = 4.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
} else {
Text(
text = data.emoji,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 4.dp)
)
Spacer(modifier = Modifier.weight(1f))
}
Text(
text = data.label,
style = MaterialTheme.typography.labelMedium,
@ -624,6 +661,18 @@ private fun Tile(
.size(16.dp)
)
}
if (!data.imageUrl.isNullOrBlank()) {
Icon(
imageVector = Icons.Filled.CameraAlt,
contentDescription = "Photo",
tint = content,
modifier = Modifier
.align(Alignment.BottomEnd)
.size(18.dp)
.background(container.copy(alpha = 0.8f), CircleShape)
.padding(2.dp)
)
}
if (!data.tag.isNullOrBlank()) {
val tagColor = when (data.tag.lowercase()) {
"urgent" -> statusColors.danger
@ -896,6 +945,7 @@ private fun ItemDetailSheet(
onOpenProduct: (() -> Unit)?
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val context = LocalContext.current
var note by remember(item.id) { mutableStateOf(item.note.orEmpty()) }
var currentTag by remember(item.id) { mutableStateOf(item.tag) }
var showCategoryPicker by remember { mutableStateOf(false) }
@ -906,7 +956,14 @@ private fun ItemDetailSheet(
val photoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { onUpdateImage(it.toString()) }
uri?.let {
val bmp = context.contentResolver.openInputStream(it)?.use { stream ->
BitmapFactory.decodeStream(stream)
}
bmp?.let { bitmap ->
onUpdateImage(saveCroppedImage(context, bitmap))
}
}
}
ModalBottomSheet(
@ -950,6 +1007,19 @@ private fun ItemDetailSheet(
}
}
// Photo de l'article
if (!item.imageUrl.isNullOrBlank()) {
AsyncImage(
model = item.imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop
)
}
// Quantité / description
OutlinedTextField(
value = note,

View File

@ -1,5 +1,6 @@
package com.safebite.app.presentation.screen.onboarding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -8,11 +9,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -23,6 +29,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -38,13 +45,15 @@ import com.safebite.app.domain.model.DietaryRestriction
import com.safebite.app.presentation.common.components.PrimaryButton
import com.safebite.app.presentation.common.components.StandardTextField
import com.safebite.app.presentation.common.components.TertiaryButton
import com.safebite.app.presentation.screen.profile.AllergenGrid
import com.safebite.app.presentation.common.components.AllergenLevel
import com.safebite.app.presentation.common.components.AllergenSelectionGrid
import com.safebite.app.presentation.screen.profile.CustomItemAdder
import com.safebite.app.presentation.screen.profile.CustomItemsList
import com.google.accompanist.permissions.shouldShowRationale
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Switch
@OptIn(ExperimentalPermissionsApi::class)
@Composable
@ -55,8 +64,8 @@ fun OnboardingScreen(
var step by rememberSaveable { mutableStateOf(0) }
var name by rememberSaveable { mutableStateOf("") }
var avatar by rememberSaveable { mutableStateOf("🙂") }
val severe = remember { mutableStateOf<Set<AllergenType>>(emptySet()) }
val moderate = remember { mutableStateOf<Set<AllergenType>>(emptySet()) }
var isDefault by rememberSaveable { mutableStateOf(true) }
val allergenLevels = remember { mutableStateOf<Map<AllergenType, AllergenLevel>>(emptyMap()) }
val restrictions = remember { mutableStateOf<Set<DietaryRestriction>>(emptySet()) }
val customItems = remember { mutableStateOf<List<CustomDietItem>>(emptyList()) }
@ -71,13 +80,15 @@ fun OnboardingScreen(
onNameChange = { name = it },
avatar = avatar,
onAvatarChange = { avatar = it },
severe = severe.value,
onToggleSevere = { a ->
severe.value = if (a in severe.value) severe.value - a else severe.value + a
},
moderate = moderate.value,
onToggleModerate = { a ->
moderate.value = if (a in moderate.value) moderate.value - a else moderate.value + a
isDefault = isDefault,
onSetDefault = { isDefault = it },
allergenLevels = allergenLevels.value,
onSetAllergenLevel = { a, level ->
allergenLevels.value = if (level == AllergenLevel.NONE) {
allergenLevels.value - a
} else {
allergenLevels.value + (a to level)
}
},
restrictions = restrictions.value,
onToggleRestriction = { r ->
@ -91,7 +102,9 @@ fun OnboardingScreen(
customItems.value = customItems.value - item
},
onNext = {
viewModel.createProfile(name, avatar, severe.value, moderate.value, restrictions.value, customItems.value)
val severe = allergenLevels.value.filterValues { it == AllergenLevel.SEVERE }.keys
val moderate = allergenLevels.value.filterValues { it == AllergenLevel.TRACE }.keys
viewModel.createProfile(name, avatar, severe, moderate, restrictions.value, customItems.value)
step = 3
}
)
@ -143,17 +156,72 @@ private fun WelcomeStep(onNext: () -> Unit) {
private fun HowStep(onNext: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Text(
stringResource(R.string.onboarding_how_title),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Text(
stringResource(R.string.onboarding_how_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
Text("👤 " + stringResource(R.string.onboarding_how_step1), style = MaterialTheme.typography.bodyLarge)
Text("📷 " + stringResource(R.string.onboarding_how_step2), style = MaterialTheme.typography.bodyLarge)
Text("" + stringResource(R.string.onboarding_how_step3), style = MaterialTheme.typography.bodyLarge)
val steps = listOf(
Triple("1", "👤", stringResource(R.string.onboarding_how_step1)),
Triple("2", "📷", stringResource(R.string.onboarding_how_step2)),
Triple("3", "", stringResource(R.string.onboarding_how_step3))
)
for ((number, emoji, label) in steps) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.size(48.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
CircleShape
),
contentAlignment = Alignment.Center
) {
Text(
text = number,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold
)
}
Column {
Text(
text = emoji,
style = MaterialTheme.typography.headlineSmall
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
Spacer(Modifier.weight(1f))
PrimaryButton(
text = stringResource(R.string.action_continue),
@ -171,10 +239,10 @@ private fun CreateProfileStep(
onNameChange: (String) -> Unit,
avatar: String,
onAvatarChange: (String) -> Unit,
severe: Set<AllergenType>,
onToggleSevere: (AllergenType) -> Unit,
moderate: Set<AllergenType>,
onToggleModerate: (AllergenType) -> Unit,
isDefault: Boolean,
onSetDefault: (Boolean) -> Unit,
allergenLevels: Map<AllergenType, AllergenLevel>,
onSetAllergenLevel: (AllergenType, AllergenLevel) -> Unit,
restrictions: Set<DietaryRestriction>,
onToggleRestriction: (DietaryRestriction) -> Unit,
customItems: List<CustomDietItem>,
@ -183,10 +251,11 @@ private fun CreateProfileStep(
onNext: () -> Unit
) {
val avatars = listOf("🙂", "😀", "👧", "👦", "👨", "👩", "👵", "👴", "🧑", "👶", "🧒", "🧓", "🍽️", "🛒", "🥗", "🍎")
val dimens = com.safebite.app.presentation.theme.LocalDimens.current
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
) {
item {
Text(
@ -222,18 +291,24 @@ private fun CreateProfileStep(
}
}
}
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = isDefault, onCheckedChange = onSetDefault)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.profile_set_default))
}
}
item {
Text(stringResource(R.string.profile_allergies), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.profile_allergies_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.profile_allergies_3states_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
item { AllergenGrid(selected = severe, onToggle = onToggleSevere) }
item {
Text(stringResource(R.string.profile_intolerances), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.profile_intolerances_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
AllergenSelectionGrid(
selectedAllergens = allergenLevels,
onLevelChanged = onSetAllergenLevel
)
}
item { AllergenGrid(selected = moderate, onToggle = onToggleModerate) }
item {
Text(stringResource(R.string.profile_restrictions), style = MaterialTheme.typography.titleMedium)
@ -262,7 +337,7 @@ private fun CreateProfileStep(
item {
PrimaryButton(
text = stringResource(R.string.action_continue),
text = stringResource(R.string.action_save),
onClick = onNext,
enabled = name.isNotBlank(),
large = true,

View File

@ -19,6 +19,7 @@
<string name="onboarding_welcome_title">Welcome to SafeBite</string>
<string name="onboarding_welcome_subtitle">Scan, verify, eat safely.</string>
<string name="onboarding_how_title">How it works</string>
<string name="onboarding_how_subtitle">In 3 simple steps, scan your products and eat safely.</string>
<string name="onboarding_how_step1">1. Create an allergy profile</string>
<string name="onboarding_how_step2">2. Scan the product barcode</string>
<string name="onboarding_how_step3">3. Get an instant verdict</string>

View File

@ -21,6 +21,7 @@
<string name="onboarding_welcome_title">Bienvenue sur SafeBite</string>
<string name="onboarding_welcome_subtitle">Scannez, vérifiez, mangez en toute sécurité.</string>
<string name="onboarding_how_title">Comment ça fonctionne</string>
<string name="onboarding_how_subtitle">En 3 étapes simples, scannez vos produits et mangez en toute sécurité.</string>
<string name="onboarding_how_step1">1. Créez un profil d\'allergies</string>
<string name="onboarding_how_step2">2. Scannez le code-barres du produit</string>
<string name="onboarding_how_step3">3. Obtenez un verdict instantané</string>

View File

@ -17,6 +17,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}

View File

@ -1,4 +1,4 @@
MAJOR=1
MINOR=8
MINOR=9
PATCH=0
CODE=10
CODE=11