diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt index 21c15ca..232e854 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt @@ -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, diff --git a/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingScreen.kt index cbda61b..059df16 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingScreen.kt @@ -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>(emptySet()) } - val moderate = remember { mutableStateOf>(emptySet()) } + var isDefault by rememberSaveable { mutableStateOf(true) } + val allergenLevels = remember { mutableStateOf>(emptyMap()) } val restrictions = remember { mutableStateOf>(emptySet()) } val customItems = remember { mutableStateOf>(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, - onToggleSevere: (AllergenType) -> Unit, - moderate: Set, - onToggleModerate: (AllergenType) -> Unit, + isDefault: Boolean, + onSetDefault: (Boolean) -> Unit, + allergenLevels: Map, + onSetAllergenLevel: (AllergenType, AllergenLevel) -> Unit, restrictions: Set, onToggleRestriction: (DietaryRestriction) -> Unit, customItems: List, @@ -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 { - Text(stringResource(R.string.profile_allergies), style = MaterialTheme.typography.titleMedium) - Text(stringResource(R.string.profile_allergies_help), color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = isDefault, onCheckedChange = onSetDefault) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.profile_set_default)) + } } - 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) + Text(stringResource(R.string.profile_allergies), style = MaterialTheme.typography.titleMedium) + 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 { 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, diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 2030213..01969be 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -19,6 +19,7 @@ Welcome to SafeBite Scan, verify, eat safely. How it works + In 3 simple steps, scan your products and eat safely. 1. Create an allergy profile 2. Scan the product barcode 3. Get an instant verdict diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 859256d..0796dfe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ Bienvenue sur SafeBite Scannez, vérifiez, mangez en toute sécurité. Comment ça fonctionne + En 3 étapes simples, scannez vos produits et mangez en toute sécurité. 1. Créez un profil d\'allergies 2. Scannez le code-barres du produit 3. Obtenez un verdict instantané diff --git a/settings.gradle.kts b/settings.gradle.kts index 98ae1d3..e3a542a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,6 +17,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } } diff --git a/version.properties b/version.properties index 92f92d1..c1ca31b 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ MAJOR=1 -MINOR=8 +MINOR=9 PATCH=0 -CODE=10 +CODE=11