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:
parent
4ac951cf6e
commit
48a9266942
@ -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,8 +163,13 @@ fun ListDetailScreen(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
selectedImageUri = it.toString()
|
||||
showDescriptionDialog = true
|
||||
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()
|
||||
) {
|
||||
Text(
|
||||
text = data.emoji,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -17,6 +17,7 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
MAJOR=1
|
||||
MINOR=8
|
||||
MINOR=9
|
||||
PATCH=0
|
||||
CODE=10
|
||||
CODE=11
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user