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

View File

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

View File

@ -19,6 +19,7 @@
<string name="onboarding_welcome_title">Welcome to SafeBite</string> <string name="onboarding_welcome_title">Welcome to SafeBite</string>
<string name="onboarding_welcome_subtitle">Scan, verify, eat safely.</string> <string name="onboarding_welcome_subtitle">Scan, verify, eat safely.</string>
<string name="onboarding_how_title">How it works</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_step1">1. Create an allergy profile</string>
<string name="onboarding_how_step2">2. Scan the product barcode</string> <string name="onboarding_how_step2">2. Scan the product barcode</string>
<string name="onboarding_how_step3">3. Get an instant verdict</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_title">Bienvenue sur SafeBite</string>
<string name="onboarding_welcome_subtitle">Scannez, vérifiez, mangez en toute sécurité.</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_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_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_step2">2. Scannez le code-barres du produit</string>
<string name="onboarding_how_step3">3. Obtenez un verdict instantané</string> <string name="onboarding_how_step3">3. Obtenez un verdict instantané</string>

View File

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

View File

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