diff --git a/app/src/main/assets/catalog_seed.json b/app/src/main/assets/catalog_seed.json index 890c57d..f5bb725 100644 --- a/app/src/main/assets/catalog_seed.json +++ b/app/src/main/assets/catalog_seed.json @@ -1,5 +1,5 @@ { - "version": 4, + "version": 5, "domains": [ { "domainId": "grocery", @@ -20,7 +20,8 @@ "name": "Pomme", "emoji": "🍎", "aliases": "apple|apples|pommes", - "tags": "fruit" + "tags": "fruit", + "variants": "Gala,Cortland,Honeycrisp,Granny Smith,Fuji,McIntosh" }, { "itemId": "banane", @@ -69,7 +70,8 @@ "name": "Tomate", "emoji": "🍅", "aliases": "tomatoes", - "tags": "legume" + "tags": "legume", + "variants": "Baby,Cherry,Diced,Roma,Sundried" }, { "itemId": "salade", @@ -83,7 +85,8 @@ "name": "Carotte", "emoji": "đŸ„•", "aliases": "carrots|carrotte", - "tags": "legume" + "tags": "legume", + "variants": "Baby,RĂąpĂ©e,Bio" }, { "itemId": "brocoli", @@ -104,7 +107,8 @@ "name": "Poivron", "emoji": "đŸ«‘", "aliases": "bell pepper|bell peppers", - "tags": "legume" + "tags": "legume", + "variants": "Rouge,Vert,Jaune,Orange" }, { "itemId": "avocat", @@ -118,7 +122,8 @@ "name": "Oignon", "emoji": "🧅", "aliases": "onions", - "tags": "legume" + "tags": "legume", + "variants": "Blanc,Rouge,Vert,Espagnol" }, { "itemId": "ail", @@ -132,14 +137,16 @@ "name": "Pomme de terre", "emoji": "đŸ„”", "aliases": "patate|patates|potatoes", - "tags": "legume" + "tags": "legume", + "variants": "RĂ©guliĂšre,Grelot,Douce,Russet,Yukon Gold" }, { "itemId": "champignon", "name": "Champignon", "emoji": "🍄", "aliases": "mushrooms|champignons", - "tags": "legume" + "tags": "legume", + "variants": "Blanc,Portobello,Shiitake,Crimini" }, { "itemId": "epinard", @@ -1267,7 +1274,8 @@ "name": "Lait", "emoji": "đŸ„›", "aliases": "milk", - "tags": "laitier" + "tags": "laitier", + "variants": "Entier,2%,1%,ÉcrĂ©mĂ©,Sans Lactose,Avoine,Amande,Soya" }, { "itemId": "lait_amande", @@ -1324,7 +1332,8 @@ "name": "Yaourt", "emoji": "đŸ„Ł", "aliases": "yogurt", - "tags": "laitier" + "tags": "laitier", + "variants": "Grec,Nature,Vanille,Fraise,Sans Lactose" }, { "itemId": "yaourt_grec", @@ -1337,7 +1346,8 @@ "name": "ƒufs", "emoji": "đŸ„š", "aliases": "eggs", - "tags": "laitier" + "tags": "laitier", + "variants": "Gros,TrĂšs Gros,Moyen,Bio,Libre Parcours" }, { "itemId": "beurre_sans_lactose", diff --git a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt index 1957f81..53648b5 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt @@ -32,7 +32,7 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity CatalogItemEntity::class, ItemCategoryCrossRef::class ], - version = 8, + version = 9, exportSchema = false ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt b/app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt index 775e84c..bb329fd 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt @@ -69,6 +69,7 @@ data class CatalogItemEntity( val barcode: String? = null, val aliases: String = "", val tags: String = "", + val variants: String = "", val isUserCreated: Boolean = false, val popularity: Int = 0, val sortOrder: Int = 0 diff --git a/app/src/main/java/com/safebite/app/data/local/database/migration/Migration8To9.kt b/app/src/main/java/com/safebite/app/data/local/database/migration/Migration8To9.kt new file mode 100644 index 0000000..c9c45ed --- /dev/null +++ b/app/src/main/java/com/safebite/app/data/local/database/migration/Migration8To9.kt @@ -0,0 +1,16 @@ +package com.safebite.app.data.local.database.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Migration additive : ajoute la colonne `variants` Ă  la table `catalog_items`. + * Les donnĂ©es existantes conservent la valeur par dĂ©faut (''). + */ +val MIGRATION_8_9: Migration = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE catalog_items ADD COLUMN variants TEXT NOT NULL DEFAULT ''" + ) + } +} diff --git a/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt index d8e747e..d336b20 100644 --- a/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt +++ b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt @@ -91,6 +91,7 @@ class CatalogSeedManager @Inject constructor( emoji = itemSeed.emoji, aliases = itemSeed.aliases.orEmpty(), tags = itemSeed.tags.orEmpty(), + variants = itemSeed.variants.orEmpty(), barcode = itemSeed.barcode, sortOrder = index ) diff --git a/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt index 0d8aac5..e3781ee 100644 --- a/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt +++ b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt @@ -40,5 +40,6 @@ data class ItemSeed( val emoji: String, val aliases: String? = null, val tags: String? = null, + val variants: String? = null, val barcode: String? = null ) diff --git a/app/src/main/java/com/safebite/app/di/DatabaseModule.kt b/app/src/main/java/com/safebite/app/di/DatabaseModule.kt index d8f02ba..ee9e176 100644 --- a/app/src/main/java/com/safebite/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/safebite/app/di/DatabaseModule.kt @@ -9,6 +9,7 @@ import com.safebite.app.data.local.database.dao.ScanHistoryDao import com.safebite.app.data.local.database.dao.ShoppingListDao import com.safebite.app.data.local.database.dao.UserProfileDao import com.safebite.app.data.local.database.migration.MIGRATION_7_8 +import com.safebite.app.data.local.database.migration.MIGRATION_8_9 import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,7 +25,7 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext context: Context): SafeBiteDatabase = Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME) - .addMigrations(MIGRATION_7_8) + .addMigrations(MIGRATION_7_8, MIGRATION_8_9) .fallbackToDestructiveMigration() .build() diff --git a/app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt b/app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt index 6f1ef67..a3325b0 100644 --- a/app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt +++ b/app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt @@ -19,7 +19,8 @@ class CatalogProvider @Inject constructor() { val name: String, val category: String, val emoji: String, - val aliases: List = emptyList() + val aliases: List = emptyList(), + val variants: List = emptyList() ) { fun matches(query: String): Boolean { val q = query.trim().lowercase() @@ -50,24 +51,24 @@ class CatalogProvider @Inject constructor() { /** Liste plate du catalogue. */ val items: List = buildList { // Fruits & LĂ©gumes - add(CatalogItem("Pomme", "Fruits & LĂ©gumes", "🍎", listOf("apple", "apples", "pommes"))) + add(CatalogItem("Pomme", "Fruits & LĂ©gumes", "🍎", listOf("apple", "apples", "pommes"), listOf("Gala", "Cortland", "Honeycrisp", "Granny Smith", "Fuji", "McIntosh"))) add(CatalogItem("Banane", "Fruits & LĂ©gumes", "🍌", listOf("banana", "bananas"))) add(CatalogItem("Orange", "Fruits & LĂ©gumes", "🍊", listOf("oranges"))) add(CatalogItem("Citron", "Fruits & LĂ©gumes", "🍋", listOf("lemon"))) add(CatalogItem("Fraise", "Fruits & LĂ©gumes", "🍓", listOf("strawberry", "strawberries"))) - add(CatalogItem("Raisin", "Fruits & LĂ©gumes", "🍇", listOf("grapes"))) + add(CatalogItem("Raisin", "Fruits & LĂ©gumes", "🍇", listOf("grapes"), listOf("Vert", "Rouge", "Sans pĂ©pins"))) add(CatalogItem("Poire", "Fruits & LĂ©gumes", "🍐", listOf("pear", "pears", "poires"))) - add(CatalogItem("Tomate", "Fruits & LĂ©gumes", "🍅", listOf("tomatoes"))) + add(CatalogItem("Tomate", "Fruits & LĂ©gumes", "🍅", listOf("tomatoes"), listOf("Baby", "Cherry", "Diced", "Roma", "Sundried"))) add(CatalogItem("Salade", "Fruits & LĂ©gumes", "đŸ„Ź", listOf("lettuce", "salad"))) - add(CatalogItem("Carotte", "Fruits & LĂ©gumes", "đŸ„•", listOf("carrots", "carrotte"))) + add(CatalogItem("Carotte", "Fruits & LĂ©gumes", "đŸ„•", listOf("carrots", "carrotte"), listOf("Baby", "RĂąpĂ©e", "Bio"))) add(CatalogItem("Brocoli", "Fruits & LĂ©gumes", "đŸ„Š", listOf("broccoli"))) add(CatalogItem("Concombre", "Fruits & LĂ©gumes", "đŸ„’", listOf("cucumber"))) - add(CatalogItem("Poivron", "Fruits & LĂ©gumes", "đŸ«‘", listOf("bell pepper", "bell peppers"))) + add(CatalogItem("Poivron", "Fruits & LĂ©gumes", "đŸ«‘", listOf("bell pepper", "bell peppers"), listOf("Rouge", "Vert", "Jaune", "Orange"))) add(CatalogItem("Avocat", "Fruits & LĂ©gumes", "đŸ„‘", listOf("avocado"))) - add(CatalogItem("Oignon", "Fruits & LĂ©gumes", "🧅", listOf("onions"))) + add(CatalogItem("Oignon", "Fruits & LĂ©gumes", "🧅", listOf("onions"), listOf("Blanc", "Rouge", "Vert", "Espagnol", "Vidalia"))) add(CatalogItem("Ail", "Fruits & LĂ©gumes", "🧄", listOf("garlic"))) - add(CatalogItem("Pomme de terre", "Fruits & LĂ©gumes", "đŸ„”", listOf("patate", "patates", "potatoes"))) - add(CatalogItem("Champignon", "Fruits & LĂ©gumes", "🍄", listOf("mushrooms", "champignons"))) + add(CatalogItem("Pomme de terre", "Fruits & LĂ©gumes", "đŸ„”", listOf("patate", "patates", "potatoes"), listOf("RĂ©guliĂšre", "Grelot", "Douce", "Russet", "Yukon Gold"))) + add(CatalogItem("Champignon", "Fruits & LĂ©gumes", "🍄", listOf("mushrooms", "champignons"), listOf("Blanc", "Portobello", "Shiitake", "Crimini"))) add(CatalogItem("Épinard", "Fruits & LĂ©gumes", "đŸ„Ź", listOf("spinach"))) add(CatalogItem("Ananas", "Fruits & LĂ©gumes", "🍍", listOf("pineapple"))) add(CatalogItem("PĂȘche", "Fruits & LĂ©gumes", "🍑", listOf("peach"))) @@ -79,7 +80,7 @@ class CatalogProvider @Inject constructor() { add(CatalogItem("Noix de coco", "Fruits & LĂ©gumes", "đŸ„„", listOf("coconut"))) add(CatalogItem("Aubergine", "Fruits & LĂ©gumes", "🍆", listOf("eggplant"))) add(CatalogItem("MaĂŻs", "Fruits & LĂ©gumes", "đŸŒœ", listOf("sweet corn", "corncobs", "corn cobs"))) - add(CatalogItem("Piment", "Fruits & LĂ©gumes", "đŸŒ¶ïž", listOf("chillies", "chili", "piment jaune"))) + add(CatalogItem("Piment", "Fruits & LĂ©gumes", "đŸŒ¶ïž", listOf("chillies", "chili", "piment jaune"), listOf("Jalapeño", "Serrano", "Habanero", "Chipotle"))) add(CatalogItem("Courgette", "Fruits & LĂ©gumes", "đŸ„’", listOf("zucchini"))) add(CatalogItem("Chou-fleur", "Fruits & LĂ©gumes", "đŸ„Š", listOf("cauliflower"))) add(CatalogItem("Chou", "Fruits & LĂ©gumes", "đŸ„Ź", listOf("cabbage"))) @@ -176,7 +177,7 @@ class CatalogProvider @Inject constructor() { add(CatalogItem("Baies", "Fruits & LĂ©gumes", "đŸ«", listOf("berries"))) // Boulangerie - add(CatalogItem("Pain", "Boulangerie", "🍞", listOf("baguette"))) + add(CatalogItem("Pain", "Boulangerie", "🍞", listOf("baguette"), listOf("Blanc", "Brun", "Complet", "Sans Gluten", "Multigrains"))) add(CatalogItem("Baguette", "Boulangerie", "đŸ„–")) add(CatalogItem("Croissant", "Boulangerie", "đŸ„")) add(CatalogItem("Brioche", "Boulangerie", "đŸ„Ż")) @@ -244,12 +245,12 @@ class CatalogProvider @Inject constructor() { add(CatalogItem("Waffles", "Boulangerie", "🧇")) // Produits laitiers - add(CatalogItem("Lait", "Produits laitiers", "đŸ„›", listOf("milk"))) - add(CatalogItem("Yaourt", "Produits laitiers", "đŸ„Ł", listOf("yogurt"))) + add(CatalogItem("Lait", "Produits laitiers", "đŸ„›", listOf("milk"), listOf("Entier", "2%", "1%", "ÉcrĂ©mĂ©", "Sans Lactose", "Avoine", "Amande", "Soya"))) + add(CatalogItem("Yaourt", "Produits laitiers", "đŸ„Ł", listOf("yogurt"), listOf("Grec", "Nature", "Vaniille", "Fraise", "Sans Lactose"))) add(CatalogItem("Beurre", "Produits laitiers", "🧈")) - add(CatalogItem("Fromage", "Produits laitiers", "🧀", listOf("cheese"))) + add(CatalogItem("Fromage", "Produits laitiers", "🧀", listOf("cheese"), listOf("Cheddar", "Mozzarella", "ParmĂ©san", "Feta", "Brie", "Emmental", "ChĂšvre"))) add(CatalogItem("CrĂšme fraĂźche", "Produits laitiers", "đŸ„›")) - add(CatalogItem("ƒufs", "Produits laitiers", "đŸ„š", listOf("oeufs", "eggs"))) + add(CatalogItem("ƒufs", "Produits laitiers", "đŸ„š", listOf("oeufs", "eggs"), listOf("Gros", "TrĂšs Gros", "Moyen", "Bio", "Libre Parcours"))) add(CatalogItem("Mozzarella", "Produits laitiers", "🧀")) add(CatalogItem("Parmesan", "Produits laitiers", "🧀")) add(CatalogItem("Cheddar", "Produits laitiers", "🧀")) @@ -324,14 +325,14 @@ class CatalogProvider @Inject constructor() { add(CatalogItem("Yogourt Sans Lactose", "Produits laitiers", "đŸ„Ł")) // Boucherie - add(CatalogItem("Poulet", "Boucherie", "🍗")) + add(CatalogItem("Poulet", "Boucherie", "🍗", emptyList(), listOf("Poitrine", "Cuisse", "Aile", "Entier", "HachĂ©"))) add(CatalogItem("BƓuf hachĂ©", "Boucherie", "đŸ„©", listOf("beef"))) add(CatalogItem("Steak", "Boucherie", "đŸ„©")) add(CatalogItem("Porc", "Boucherie", "đŸ„“")) add(CatalogItem("Jambon", "Boucherie", "đŸ„“")) - add(CatalogItem("Saucisse", "Boucherie", "🌭")) + add(CatalogItem("Saucisse", "Boucherie", "🌭", emptyList(), listOf("Porc", "Veau", "Dinde", "Italienne", "Cocktail"))) add(CatalogItem("Bacon", "Boucherie", "đŸ„“")) - add(CatalogItem("Saumon", "Boucherie", "🐟")) + add(CatalogItem("Saumon", "Boucherie", "🐟", emptyList(), listOf("Atlantique", "Pacifique", "Sockeye", "FumĂ©", "En Conserve"))) add(CatalogItem("Thon", "Boucherie", "🐟")) add(CatalogItem("Dinde", "Boucherie", "🩃")) add(CatalogItem("Canard", "Boucherie", "🩆")) 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 a562fa3..c3f9908 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 @@ -24,9 +24,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -77,6 +81,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -88,6 +93,8 @@ import java.io.File import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color @@ -139,6 +146,8 @@ fun ListDetailScreen( val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val suggestions by viewModel.suggestions.collectAsStateWithLifecycle() val selectedItemId by viewModel.selectedItemId.collectAsStateWithLifecycle() + val pendingItem by viewModel.pendingItem.collectAsStateWithLifecycle() + val isSearchActive by viewModel.isSearchActive.collectAsStateWithLifecycle() val otherLists by viewModel.otherLists.collectAsStateWithLifecycle() var menuExpanded by remember { mutableStateOf(false) } @@ -255,12 +264,17 @@ fun ListDetailScreen( bottomBar = { BottomSearchBar( query = searchQuery, - onQueryChange = viewModel::updateSearchQuery, + isSearchActive = isSearchActive || pendingItem != null, + hasPendingItem = pendingItem != null, + onQueryChange = { q -> + if (pendingItem != null) viewModel.closePendingItem() + viewModel.updateSearchQuery(q) + }, + onActivate = viewModel::activateSearch, onClear = viewModel::clearSearch, + onCancel = viewModel::cancelSearch, onAddCustom = { - if (searchQuery.isNotBlank()) { - viewModel.addCustomItem(searchQuery) - } + if (searchQuery.isNotBlank()) viewModel.addCustomItem(searchQuery) } ) } @@ -304,9 +318,23 @@ fun ListDetailScreen( } } - // Overlay des suggestions (au-dessus du contenu, sous la barre de saisie). + // Overlay : panneau de dĂ©tail inline (aprĂšs sĂ©lection) ou suggestions. AnimatedVisibility( - visible = suggestions.isNotEmpty(), + visible = pendingItem != null, + modifier = Modifier.align(Alignment.BottomCenter) + ) { + pendingItem?.let { + ItemDetailPanel( + pending = it, + onQuantity = viewModel::updatePendingQuantity, + onVariant = viewModel::updatePendingVariant, + onTag = viewModel::updatePendingTag, + onNote = viewModel::updatePendingNote + ) + } + } + AnimatedVisibility( + visible = pendingItem == null && suggestions.isNotEmpty(), modifier = Modifier.align(Alignment.BottomCenter) ) { SuggestionPanel( @@ -897,10 +925,25 @@ private fun EmptyActiveCard() { @Composable private fun BottomSearchBar( query: String, + isSearchActive: Boolean, + hasPendingItem: Boolean, onQueryChange: (String) -> Unit, + onActivate: () -> Unit, onClear: () -> Unit, + onCancel: () -> Unit, onAddCustom: () -> Unit ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(isSearchActive) { + if (isSearchActive) { + runCatching { focusRequester.requestFocus() } + } + } + val placeholder = when { + hasPendingItem -> "Article suivant
" + isSearchActive -> "p. ex. pain" + else -> "J'ai besoin
" + } Surface( color = MaterialTheme.colorScheme.surface, tonalElevation = 4.dp @@ -917,9 +960,11 @@ private fun BottomSearchBar( TextField( value = query, onValueChange = onQueryChange, - placeholder = { Text("J'ai besoin
") }, + placeholder = { Text(placeholder) }, singleLine = true, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), shape = RoundedCornerShape(28.dp), trailingIcon = { if (query.isNotEmpty()) { @@ -936,13 +981,22 @@ private fun BottomSearchBar( disabledIndicatorColor = Color.Transparent ) ) - FloatingActionButton( - onClick = onAddCustom, - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(48.dp) - ) { - Icon(Icons.Filled.Add, contentDescription = "Ajouter") + if (isSearchActive) { + TextButton(onClick = onCancel) { + Text("Annuler") + } + } else { + FloatingActionButton( + onClick = { + onActivate() + runCatching { focusRequester.requestFocus() } + }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.Filled.Add, contentDescription = "Ajouter") + } } } } @@ -957,78 +1011,246 @@ private fun SuggestionPanel( suggestions: List, onPick: (ListDetailViewModel.Suggestion) -> Unit ) { + val tealColor = Color(0xFF26A69A) Surface( modifier = Modifier .fillMaxWidth() - .heightIn(max = 320.dp), + .heightIn(max = 340.dp), color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp, shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) { - Column( - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 8.dp) - .verticalScroll(rememberScrollState()) + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(bottom = 8.dp) ) { - suggestions.forEach { suggestion -> - SuggestionRow( + items(suggestions) { suggestion -> + SuggestionTile( suggestion = suggestion, + tealColor = tealColor, onPick = { onPick(suggestion) } ) } - Spacer(modifier = Modifier.height(72.dp)) // espace pour la barre de saisie } } } @Composable -private fun SuggestionRow( +private fun SuggestionTile( suggestion: ListDetailViewModel.Suggestion, + tealColor: Color, onPick: () -> Unit ) { - val (badge, badgeColor) = when (suggestion) { - is ListDetailViewModel.Suggestion.Active -> "Sur la liste" to MaterialTheme.colorScheme.tertiary - is ListDetailViewModel.Suggestion.Recent -> "Recently Used" to LocalStatusColors.current.safe - is ListDetailViewModel.Suggestion.Catalog -> suggestion.item.category to MaterialTheme.colorScheme.onSurfaceVariant - is ListDetailViewModel.Suggestion.RoomCatalog -> (suggestion.categoryName ?: "Catalogue") to MaterialTheme.colorScheme.onSurfaceVariant - is ListDetailViewModel.Suggestion.Create -> "CrĂ©er" to MaterialTheme.colorScheme.primary + val isCreate = suggestion is ListDetailViewModel.Suggestion.Create + val isActive = suggestion is ListDetailViewModel.Suggestion.Active + val containerColor = when { + isCreate -> MaterialTheme.colorScheme.primaryContainer + isActive -> MaterialTheme.colorScheme.tertiaryContainer + else -> tealColor.copy(alpha = 0.15f) } - Row( + val contentColor = when { + isCreate -> MaterialTheme.colorScheme.onPrimaryContainer + isActive -> MaterialTheme.colorScheme.onTertiaryContainer + else -> tealColor + } + Card( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onPick) - .padding(vertical = 10.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically + .aspectRatio(1f) + .clickable(onClick = onPick), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors(containerColor = containerColor) ) { - Text(text = suggestion.emoji, style = MaterialTheme.typography.titleLarge) - Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize() + ) { + Text( + text = suggestion.emoji, + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = suggestion.label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = contentColor, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + if (isCreate) { + Icon( + imageVector = Icons.Filled.AutoAwesome, + contentDescription = null, + tint = contentColor, + modifier = Modifier + .align(Alignment.TopEnd) + .size(16.dp) + ) + } + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Panneau de dĂ©tail inline (aprĂšs sĂ©lection d'un article) +// ───────────────────────────────────────────────────────────────────────────── + +@Composable +private fun ItemDetailPanel( + pending: ListDetailViewModel.PendingItem, + onQuantity: (Int?) -> Unit, + onVariant: (String?) -> Unit, + onTag: (String?) -> Unit, + onNote: (String) -> Unit +) { + var showNoteField by rememberSaveable(pending.itemId) { mutableStateOf(pending.note.isNotEmpty()) } + var noteText by rememberSaveable(pending.itemId) { mutableStateOf(pending.note) } + + val selectedColor = MaterialTheme.colorScheme.error + val selectedContent = MaterialTheme.colorScheme.onError + val idleContainer = MaterialTheme.colorScheme.surfaceVariant + val idleContent = MaterialTheme.colorScheme.onSurfaceVariant + + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 360.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { Text( - text = suggestion.label, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, + text = "DĂ©tails de l'article pour ${pending.emoji} ${pending.name}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Text( - text = badge, - style = MaterialTheme.typography.labelSmall, - color = badgeColor - ) - } - if (suggestion is ListDetailViewModel.Suggestion.Create) { - Icon( - imageVector = Icons.Filled.AutoAwesome, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - } else { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + + // QuantitĂ© 1-5 + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + (1..5).forEach { qty -> + val selected = pending.selectedQuantity == qty + Surface( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .clickable { onQuantity(if (selected) null else qty) }, + color = if (selected) selectedColor else idleContainer, + shape = CircleShape + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text( + text = qty.toString(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = if (selected) selectedContent else idleContent + ) + } + } + } + } + + // Variantes + if (pending.variants.isNotEmpty()) { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + pending.variants.forEach { variant -> + val selected = pending.selectedVariant == variant + Surface( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .clickable { onVariant(if (selected) null else variant) }, + color = if (selected) selectedColor else idleContainer, + shape = RoundedCornerShape(20.dp) + ) { + Text( + text = variant, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = if (selected) selectedContent else idleContent + ) + } + } + } + } + + // Tags prioritĂ© + val tags = listOf("⚡ Urgent", "đŸ· Offre", "📅 Quand ça convient") + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + tags.forEach { tag -> + val selected = pending.selectedTag == tag + Surface( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .clickable { onTag(if (selected) null else tag) }, + color = if (selected) selectedColor else idleContainer, + shape = RoundedCornerShape(20.dp) + ) { + Text( + text = tag, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = if (selected) selectedContent else idleContent + ) + } + } + } + + // Note + val noteButtonLabel = if (pending.selectedQuantity != null || pending.selectedVariant != null || pending.note.isNotBlank()) + "📝 Changer mes dĂ©tails" else "📝 Notez vos propres dĂ©tails" + TextButton( + onClick = { showNoteField = !showNoteField }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = noteButtonLabel, + style = MaterialTheme.typography.bodyMedium + ) + } + if (showNoteField) { + OutlinedTextField( + value = noteText, + onValueChange = { + noteText = it + onNote(it) + }, + placeholder = { Text("Ex: bio, sans gluten, marque X
") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4, + shape = RoundedCornerShape(12.dp) + ) + } } } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt index 4735eaa..0f41a17 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt @@ -75,6 +75,22 @@ class ListDetailViewModel @Inject constructor( val tag: String? ) + /** + * Article sĂ©lectionnĂ© en cours de paramĂ©trage (quantitĂ©, variante, tag, note). + * L'item a dĂ©jĂ  Ă©tĂ© ajoutĂ© Ă  la liste — ce state permet d'en modifier les dĂ©tails + * depuis le panneau inline. + */ + data class PendingItem( + val itemId: Long, + val name: String, + val emoji: String, + val variants: List, + val selectedQuantity: Int? = null, + val selectedVariant: String? = null, + val selectedTag: String? = null, + val note: String = "" + ) + /** * Suggestion affichĂ©e dans le panneau au-dessus de la barre de recherche. * Peut ĂȘtre un article existant (catalogue / recently used / actif) ou la @@ -116,6 +132,14 @@ class ListDetailViewModel @Inject constructor( private val _selectedItemId = MutableStateFlow(null) val selectedItemId: StateFlow = _selectedItemId + /** Article en cours de paramĂ©trage dans le panneau inline. */ + private val _pendingItem = MutableStateFlow(null) + val pendingItem: StateFlow = _pendingItem + + /** Vrai si la barre de recherche est active (clavier ouvert). */ + private val _isSearchActive = MutableStateFlow(false) + val isSearchActive: StateFlow = _isSearchActive + /** Listes disponibles pour l'action "DĂ©placer l'article". */ val otherLists: StateFlow> = getListsUseCase .observeActive() @@ -223,98 +247,200 @@ class ListDetailViewModel @Inject constructor( _searchQuery.value = "" } + /** Active le mode recherche (clavier visible). */ + fun activateSearch() { + _isSearchActive.value = true + } + + /** Annule la recherche : ferme le clavier, vide la saisie et le panneau de dĂ©tail. */ + fun cancelSearch() { + _isSearchActive.value = false + _searchQuery.value = "" + _pendingItem.value = null + } + + /** Ferme le panneau de dĂ©tail inline sans fermer la recherche. */ + fun closePendingItem() { + _pendingItem.value = null + } + /** - * Tap sur une suggestion : ajoute l'article Ă  la liste active. + * Tap sur une suggestion : + * 1. Ajoute immĂ©diatement l’article Ă  la liste active. + * 2. Ouvre le panneau de dĂ©tail inline (PendingItem). + * 3. Vide la saisie (mais garde le mode recherche actif). */ fun applySuggestion(suggestion: Suggestion) { - when (suggestion) { - is Suggestion.Catalog -> addCatalogItem(suggestion.item) - is Suggestion.RoomCatalog -> addCatalogItem(suggestion.item) - is Suggestion.Recent -> restoreItem(suggestion.item.id) - is Suggestion.Active -> { /* dĂ©jĂ  sur la liste, ne fait rien */ } - is Suggestion.Create -> addCustomItem(suggestion.rawText) + viewModelScope.launch { + val itemId = when (suggestion) { + is Suggestion.Catalog -> addCatalogItemAndGetId(suggestion.item) + is Suggestion.RoomCatalog -> addCatalogItemAndGetId(suggestion.item) + is Suggestion.Recent -> { + manageListUseCase.setItemChecked(suggestion.item.id, false) + suggestion.item.id + } + is Suggestion.Active -> suggestion.item.id + is Suggestion.Create -> addCustomItemAndGetId(suggestion.rawText) + } + val variants = when (suggestion) { + is Suggestion.RoomCatalog -> + suggestion.item.variants.split(",") + .map { it.trim() }.filter { it.isNotEmpty() } + is Suggestion.Catalog -> suggestion.item.variants + is Suggestion.Recent -> emptyList() + is Suggestion.Active -> emptyList() + is Suggestion.Create -> emptyList() + } + val existingItem = manageListUseCase.getItems(_listIdFlow.value) + .firstOrNull { it.id == itemId } + _pendingItem.value = PendingItem( + itemId = itemId, + name = suggestion.label, + emoji = suggestion.emoji, + variants = variants, + selectedTag = existingItem?.tag, + note = existingItem?.note.orEmpty() + ) + _searchQuery.value = "" } - clearSearch() } - /** Ajoute un article du catalogue legacy Ă  la liste active. */ - fun addCatalogItem(catalogItem: CatalogProvider.CatalogItem) { + /** Met Ă  jour la quantitĂ© sĂ©lectionnĂ©e et sauvegarde en DB. */ + fun updatePendingQuantity(qty: Int?) { + val pending = _pendingItem.value ?: return + val updated = pending.copy(selectedQuantity = qty) + _pendingItem.value = updated + savePendingNote(updated) + } + + /** Met Ă  jour la variante sĂ©lectionnĂ©e et sauvegarde en DB. */ + fun updatePendingVariant(variant: String?) { + val pending = _pendingItem.value ?: return + val updated = pending.copy(selectedVariant = variant) + _pendingItem.value = updated + savePendingNote(updated) + } + + /** Met Ă  jour le tag prioritĂ© et sauvegarde en DB. */ + fun updatePendingTag(tag: String?) { + val pending = _pendingItem.value ?: return + _pendingItem.value = pending.copy(selectedTag = tag) viewModelScope.launch { val listId = _listIdFlow.value - val existing = manageListUseCase.getItems(listId) - .firstOrNull { it.productName.equals(catalogItem.name, ignoreCase = true) } - if (existing != null) { - if (existing.isChecked) { - manageListUseCase.setItemChecked(existing.id, false) - } - return@launch - } - manageListUseCase.addItemToList( - listId, - ShoppingListItemEntity( - listId = listId, - productName = catalogItem.name, - category = catalogItem.category - ) - ) + val item = manageListUseCase.getItems(listId) + .firstOrNull { it.id == pending.itemId } ?: return@launch + manageListUseCase.updateItem(item.copy(tag = tag)) } } - /** Ajoute un article du catalogue Room Ă  la liste active. */ + /** Met Ă  jour la note libre et sauvegarde en DB. */ + fun updatePendingNote(note: String) { + val pending = _pendingItem.value ?: return + val updated = pending.copy(note = note) + _pendingItem.value = updated + savePendingNote(updated) + } + + private fun savePendingNote(pending: PendingItem) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId) + .firstOrNull { it.id == pending.itemId } ?: return@launch + manageListUseCase.updateItem(item.copy(note = buildNote(pending))) + } + } + + private fun buildNote(pending: PendingItem): String? { + val parts = mutableListOf() + pending.selectedQuantity?.let { parts.add(it.toString()) } + pending.selectedVariant?.let { parts.add(it) } + val structured = parts.joinToString(", ").ifEmpty { null } + val userNote = pending.note.trim().ifEmpty { null } + return listOfNotNull(structured, userNote).joinToString(" — ").ifEmpty { null } + } + + private suspend fun addCatalogItemAndGetId(catalogItem: CatalogProvider.CatalogItem): Long { + val listId = _listIdFlow.value + val existing = manageListUseCase.getItems(listId) + .firstOrNull { it.productName.equals(catalogItem.name, ignoreCase = true) } + if (existing != null) { + if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false) + return existing.id + } + return manageListUseCase.addItem( + ShoppingListItemEntity( + listId = listId, + productName = catalogItem.name, + category = catalogItem.category + ) + ) + } + + private suspend fun addCatalogItemAndGetId(item: CatalogItemEntity): Long { + val listId = _listIdFlow.value + val existing = manageListUseCase.getItems(listId) + .firstOrNull { it.productName.equals(item.name, ignoreCase = true) } + if (existing != null) { + if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false) + return existing.id + } + val categoryName = item.primaryCategoryId?.let { catId -> + catalogRepository.getCategory(catId)?.name + } ?: categoryEngine.detectCategory(item.name) + return manageListUseCase.addItem( + ShoppingListItemEntity( + listId = listId, + productName = item.name, + category = categoryName, + customEmoji = item.emoji + ) + ) + } + + private suspend fun addCustomItemAndGetId(rawText: String): Long { + val trimmed = rawText.trim().ifEmpty { return -1L } + val (quantity, name) = parseQuantityAndName(trimmed) + val listId = _listIdFlow.value + val existing = manageListUseCase.getItems(listId) + .firstOrNull { it.productName.equals(name, ignoreCase = true) } + if (existing != null) { + if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false) + return existing.id + } + val category = categoryEngine.detectCategory(name) + return manageListUseCase.addItem( + ShoppingListItemEntity( + listId = listId, + productName = name, + category = category, + note = quantity + ) + ) + } + + /** CrĂ©e un *own item* Ă  partir de la saisie (appel direct depuis BottomSearchBar). */ + fun addCustomItem(rawText: String) { + viewModelScope.launch { addCustomItemAndGetId(rawText) } + } + + /** + * Tap sur une tuile du catalogue Room (section principale). + * Ajoute l'article et ouvre le panneau de dĂ©tail inline. + */ fun addCatalogItem(item: CatalogItemEntity) { viewModelScope.launch { - val listId = _listIdFlow.value - val existing = manageListUseCase.getItems(listId) - .firstOrNull { it.productName.equals(item.name, ignoreCase = true) } - if (existing != null) { - if (existing.isChecked) { - manageListUseCase.setItemChecked(existing.id, false) - } - return@launch - } - val categoryName = item.primaryCategoryId?.let { catId -> - catalogRepository.getCategory(catId)?.name - } ?: categoryEngine.detectCategory(item.name) - manageListUseCase.addItemToList( - listId, - ShoppingListItemEntity( - listId = listId, - productName = item.name, - category = categoryName, - customEmoji = item.emoji - ) - ) - } - } - - /** CrĂ©e un *own item* Ă  partir de la saisie utilisateur (avec quantitĂ© optionnelle). */ - fun addCustomItem(rawText: String) { - val trimmed = rawText.trim() - if (trimmed.isEmpty()) return - val (quantity, name) = parseQuantityAndName(trimmed) - - viewModelScope.launch { - val listId = _listIdFlow.value - val existing = manageListUseCase.getItems(listId) - .firstOrNull { it.productName.equals(name, ignoreCase = true) } - if (existing != null) { - if (existing.isChecked) { - manageListUseCase.setItemChecked(existing.id, false) - } - if (quantity != null && existing.note != quantity) { - manageListUseCase.updateItem(existing.copy(note = quantity, isChecked = false)) - } - return@launch - } - val category = categoryEngine.detectCategory(name) - manageListUseCase.addItemToList( - listId, - ShoppingListItemEntity( - listId = listId, - productName = name, - category = category, - note = quantity - ) + val itemId = addCatalogItemAndGetId(item) + val variants = item.variants.split(",") + .map { it.trim() }.filter { it.isNotEmpty() } + val existingItem = manageListUseCase.getItems(_listIdFlow.value) + .firstOrNull { it.id == itemId } + _pendingItem.value = PendingItem( + itemId = itemId, + name = item.name, + emoji = item.emoji, + variants = variants, + selectedTag = existingItem?.tag, + note = existingItem?.note.orEmpty() ) } } diff --git a/version.properties b/version.properties index ab8fd1a..9574f6e 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ MAJOR=1 -MINOR=24 +MINOR=25 PATCH=0 -CODE=35 +CODE=36