feat: add product variants field to catalog items with inline detail panel UI for quantity/variant/tag/note selection
- Increment catalog version from 4 to 5 and database version from 8 to 9 - Add `variants` field to `CatalogItemEntity`, `ItemSeed`, and `CatalogItem` models - Populate variants for common items (apples, tomatoes, carrots, peppers, onions, potatoes, mushrooms, milk, yogurt, eggs, cheese, chicken, sausages, salmon, bread, grapes, peppers) - Implement `ItemDetailPanel` composable with quantity
This commit is contained in:
parent
49eafca209
commit
9e021397b7
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": 4,
|
"version": 5,
|
||||||
"domains": [
|
"domains": [
|
||||||
{
|
{
|
||||||
"domainId": "grocery",
|
"domainId": "grocery",
|
||||||
@ -20,7 +20,8 @@
|
|||||||
"name": "Pomme",
|
"name": "Pomme",
|
||||||
"emoji": "🍎",
|
"emoji": "🍎",
|
||||||
"aliases": "apple|apples|pommes",
|
"aliases": "apple|apples|pommes",
|
||||||
"tags": "fruit"
|
"tags": "fruit",
|
||||||
|
"variants": "Gala,Cortland,Honeycrisp,Granny Smith,Fuji,McIntosh"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"itemId": "banane",
|
"itemId": "banane",
|
||||||
@ -69,7 +70,8 @@
|
|||||||
"name": "Tomate",
|
"name": "Tomate",
|
||||||
"emoji": "🍅",
|
"emoji": "🍅",
|
||||||
"aliases": "tomatoes",
|
"aliases": "tomatoes",
|
||||||
"tags": "legume"
|
"tags": "legume",
|
||||||
|
"variants": "Baby,Cherry,Diced,Roma,Sundried"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"itemId": "salade",
|
"itemId": "salade",
|
||||||
@ -83,7 +85,8 @@
|
|||||||
"name": "Carotte",
|
"name": "Carotte",
|
||||||
"emoji": "🥕",
|
"emoji": "🥕",
|
||||||
"aliases": "carrots|carrotte",
|
"aliases": "carrots|carrotte",
|
||||||
"tags": "legume"
|
"tags": "legume",
|
||||||
|
"variants": "Baby,Râpée,Bio"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"itemId": "brocoli",
|
"itemId": "brocoli",
|
||||||
@ -104,7 +107,8 @@
|
|||||||
"name": "Poivron",
|
"name": "Poivron",
|
||||||
"emoji": "🫑",
|
"emoji": "🫑",
|
||||||
"aliases": "bell pepper|bell peppers",
|
"aliases": "bell pepper|bell peppers",
|
||||||
"tags": "legume"
|
"tags": "legume",
|
||||||
|
"variants": "Rouge,Vert,Jaune,Orange"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"itemId": "avocat",
|
"itemId": "avocat",
|
||||||
@ -118,7 +122,8 @@
|
|||||||
"name": "Oignon",
|
"name": "Oignon",
|
||||||
"emoji": "🧅",
|
"emoji": "🧅",
|
||||||
"aliases": "onions",
|
"aliases": "onions",
|
||||||
"tags": "legume"
|
"tags": "legume",
|
||||||
|
"variants": "Blanc,Rouge,Vert,Espagnol"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"itemId": "ail",
|
"itemId": "ail",
|
||||||
@ -132,14 +137,16 @@
|
|||||||
"name": "Pomme de terre",
|
"name": "Pomme de terre",
|
||||||
"emoji": "🥔",
|
"emoji": "🥔",
|
||||||
"aliases": "patate|patates|potatoes",
|
"aliases": "patate|patates|potatoes",
|
||||||
"tags": "legume"
|
"tags": "legume",
|
||||||
|
"variants": "Régulière,Grelot,Douce,Russet,Yukon Gold"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"itemId": "champignon",
|
"itemId": "champignon",
|
||||||
"name": "Champignon",
|
"name": "Champignon",
|
||||||
"emoji": "🍄",
|
"emoji": "🍄",
|
||||||
"aliases": "mushrooms|champignons",
|
"aliases": "mushrooms|champignons",
|
||||||
"tags": "legume"
|
"tags": "legume",
|
||||||
|
"variants": "Blanc,Portobello,Shiitake,Crimini"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"itemId": "epinard",
|
"itemId": "epinard",
|
||||||
@ -1267,7 +1274,8 @@
|
|||||||
"name": "Lait",
|
"name": "Lait",
|
||||||
"emoji": "🥛",
|
"emoji": "🥛",
|
||||||
"aliases": "milk",
|
"aliases": "milk",
|
||||||
"tags": "laitier"
|
"tags": "laitier",
|
||||||
|
"variants": "Entier,2%,1%,Écrémé,Sans Lactose,Avoine,Amande,Soya"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"itemId": "lait_amande",
|
"itemId": "lait_amande",
|
||||||
@ -1324,7 +1332,8 @@
|
|||||||
"name": "Yaourt",
|
"name": "Yaourt",
|
||||||
"emoji": "🥣",
|
"emoji": "🥣",
|
||||||
"aliases": "yogurt",
|
"aliases": "yogurt",
|
||||||
"tags": "laitier"
|
"tags": "laitier",
|
||||||
|
"variants": "Grec,Nature,Vanille,Fraise,Sans Lactose"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"itemId": "yaourt_grec",
|
"itemId": "yaourt_grec",
|
||||||
@ -1337,7 +1346,8 @@
|
|||||||
"name": "Œufs",
|
"name": "Œufs",
|
||||||
"emoji": "🥚",
|
"emoji": "🥚",
|
||||||
"aliases": "eggs",
|
"aliases": "eggs",
|
||||||
"tags": "laitier"
|
"tags": "laitier",
|
||||||
|
"variants": "Gros,Très Gros,Moyen,Bio,Libre Parcours"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"itemId": "beurre_sans_lactose",
|
"itemId": "beurre_sans_lactose",
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity
|
|||||||
CatalogItemEntity::class,
|
CatalogItemEntity::class,
|
||||||
ItemCategoryCrossRef::class
|
ItemCategoryCrossRef::class
|
||||||
],
|
],
|
||||||
version = 8,
|
version = 9,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|||||||
@ -69,6 +69,7 @@ data class CatalogItemEntity(
|
|||||||
val barcode: String? = null,
|
val barcode: String? = null,
|
||||||
val aliases: String = "",
|
val aliases: String = "",
|
||||||
val tags: String = "",
|
val tags: String = "",
|
||||||
|
val variants: String = "",
|
||||||
val isUserCreated: Boolean = false,
|
val isUserCreated: Boolean = false,
|
||||||
val popularity: Int = 0,
|
val popularity: Int = 0,
|
||||||
val sortOrder: Int = 0
|
val sortOrder: Int = 0
|
||||||
|
|||||||
@ -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 ''"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -91,6 +91,7 @@ class CatalogSeedManager @Inject constructor(
|
|||||||
emoji = itemSeed.emoji,
|
emoji = itemSeed.emoji,
|
||||||
aliases = itemSeed.aliases.orEmpty(),
|
aliases = itemSeed.aliases.orEmpty(),
|
||||||
tags = itemSeed.tags.orEmpty(),
|
tags = itemSeed.tags.orEmpty(),
|
||||||
|
variants = itemSeed.variants.orEmpty(),
|
||||||
barcode = itemSeed.barcode,
|
barcode = itemSeed.barcode,
|
||||||
sortOrder = index
|
sortOrder = index
|
||||||
)
|
)
|
||||||
|
|||||||
@ -40,5 +40,6 @@ data class ItemSeed(
|
|||||||
val emoji: String,
|
val emoji: String,
|
||||||
val aliases: String? = null,
|
val aliases: String? = null,
|
||||||
val tags: String? = null,
|
val tags: String? = null,
|
||||||
|
val variants: String? = null,
|
||||||
val barcode: String? = null
|
val barcode: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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.ShoppingListDao
|
||||||
import com.safebite.app.data.local.database.dao.UserProfileDao
|
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_7_8
|
||||||
|
import com.safebite.app.data.local.database.migration.MIGRATION_8_9
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@ -24,7 +25,7 @@ object DatabaseModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideDatabase(@ApplicationContext context: Context): SafeBiteDatabase =
|
fun provideDatabase(@ApplicationContext context: Context): SafeBiteDatabase =
|
||||||
Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME)
|
Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME)
|
||||||
.addMigrations(MIGRATION_7_8)
|
.addMigrations(MIGRATION_7_8, MIGRATION_8_9)
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,8 @@ class CatalogProvider @Inject constructor() {
|
|||||||
val name: String,
|
val name: String,
|
||||||
val category: String,
|
val category: String,
|
||||||
val emoji: String,
|
val emoji: String,
|
||||||
val aliases: List<String> = emptyList()
|
val aliases: List<String> = emptyList(),
|
||||||
|
val variants: List<String> = emptyList()
|
||||||
) {
|
) {
|
||||||
fun matches(query: String): Boolean {
|
fun matches(query: String): Boolean {
|
||||||
val q = query.trim().lowercase()
|
val q = query.trim().lowercase()
|
||||||
@ -50,24 +51,24 @@ class CatalogProvider @Inject constructor() {
|
|||||||
/** Liste plate du catalogue. */
|
/** Liste plate du catalogue. */
|
||||||
val items: List<CatalogItem> = buildList {
|
val items: List<CatalogItem> = buildList {
|
||||||
// Fruits & Légumes
|
// 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("Banane", "Fruits & Légumes", "🍌", listOf("banana", "bananas")))
|
||||||
add(CatalogItem("Orange", "Fruits & Légumes", "🍊", listOf("oranges")))
|
add(CatalogItem("Orange", "Fruits & Légumes", "🍊", listOf("oranges")))
|
||||||
add(CatalogItem("Citron", "Fruits & Légumes", "🍋", listOf("lemon")))
|
add(CatalogItem("Citron", "Fruits & Légumes", "🍋", listOf("lemon")))
|
||||||
add(CatalogItem("Fraise", "Fruits & Légumes", "🍓", listOf("strawberry", "strawberries")))
|
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("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("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("Brocoli", "Fruits & Légumes", "🥦", listOf("broccoli")))
|
||||||
add(CatalogItem("Concombre", "Fruits & Légumes", "🥒", listOf("cucumber")))
|
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("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("Ail", "Fruits & Légumes", "🧄", listOf("garlic")))
|
||||||
add(CatalogItem("Pomme de terre", "Fruits & Légumes", "🥔", listOf("patate", "patates", "potatoes")))
|
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")))
|
add(CatalogItem("Champignon", "Fruits & Légumes", "🍄", listOf("mushrooms", "champignons"), listOf("Blanc", "Portobello", "Shiitake", "Crimini")))
|
||||||
add(CatalogItem("Épinard", "Fruits & Légumes", "🥬", listOf("spinach")))
|
add(CatalogItem("Épinard", "Fruits & Légumes", "🥬", listOf("spinach")))
|
||||||
add(CatalogItem("Ananas", "Fruits & Légumes", "🍍", listOf("pineapple")))
|
add(CatalogItem("Ananas", "Fruits & Légumes", "🍍", listOf("pineapple")))
|
||||||
add(CatalogItem("Pêche", "Fruits & Légumes", "🍑", listOf("peach")))
|
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("Noix de coco", "Fruits & Légumes", "🥥", listOf("coconut")))
|
||||||
add(CatalogItem("Aubergine", "Fruits & Légumes", "🍆", listOf("eggplant")))
|
add(CatalogItem("Aubergine", "Fruits & Légumes", "🍆", listOf("eggplant")))
|
||||||
add(CatalogItem("Maïs", "Fruits & Légumes", "🌽", listOf("sweet corn", "corncobs", "corn cobs")))
|
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("Courgette", "Fruits & Légumes", "🥒", listOf("zucchini")))
|
||||||
add(CatalogItem("Chou-fleur", "Fruits & Légumes", "🥦", listOf("cauliflower")))
|
add(CatalogItem("Chou-fleur", "Fruits & Légumes", "🥦", listOf("cauliflower")))
|
||||||
add(CatalogItem("Chou", "Fruits & Légumes", "🥬", listOf("cabbage")))
|
add(CatalogItem("Chou", "Fruits & Légumes", "🥬", listOf("cabbage")))
|
||||||
@ -176,7 +177,7 @@ class CatalogProvider @Inject constructor() {
|
|||||||
add(CatalogItem("Baies", "Fruits & Légumes", "🫐", listOf("berries")))
|
add(CatalogItem("Baies", "Fruits & Légumes", "🫐", listOf("berries")))
|
||||||
|
|
||||||
// Boulangerie
|
// 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("Baguette", "Boulangerie", "🥖"))
|
||||||
add(CatalogItem("Croissant", "Boulangerie", "🥐"))
|
add(CatalogItem("Croissant", "Boulangerie", "🥐"))
|
||||||
add(CatalogItem("Brioche", "Boulangerie", "🥯"))
|
add(CatalogItem("Brioche", "Boulangerie", "🥯"))
|
||||||
@ -244,12 +245,12 @@ class CatalogProvider @Inject constructor() {
|
|||||||
add(CatalogItem("Waffles", "Boulangerie", "🧇"))
|
add(CatalogItem("Waffles", "Boulangerie", "🧇"))
|
||||||
|
|
||||||
// Produits laitiers
|
// Produits laitiers
|
||||||
add(CatalogItem("Lait", "Produits laitiers", "🥛", listOf("milk")))
|
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")))
|
add(CatalogItem("Yaourt", "Produits laitiers", "🥣", listOf("yogurt"), listOf("Grec", "Nature", "Vaniille", "Fraise", "Sans Lactose")))
|
||||||
add(CatalogItem("Beurre", "Produits laitiers", "🧈"))
|
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("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("Mozzarella", "Produits laitiers", "🧀"))
|
||||||
add(CatalogItem("Parmesan", "Produits laitiers", "🧀"))
|
add(CatalogItem("Parmesan", "Produits laitiers", "🧀"))
|
||||||
add(CatalogItem("Cheddar", "Produits laitiers", "🧀"))
|
add(CatalogItem("Cheddar", "Produits laitiers", "🧀"))
|
||||||
@ -324,14 +325,14 @@ class CatalogProvider @Inject constructor() {
|
|||||||
add(CatalogItem("Yogourt Sans Lactose", "Produits laitiers", "🥣"))
|
add(CatalogItem("Yogourt Sans Lactose", "Produits laitiers", "🥣"))
|
||||||
|
|
||||||
// Boucherie
|
// 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("Bœuf haché", "Boucherie", "🥩", listOf("beef")))
|
||||||
add(CatalogItem("Steak", "Boucherie", "🥩"))
|
add(CatalogItem("Steak", "Boucherie", "🥩"))
|
||||||
add(CatalogItem("Porc", "Boucherie", "🥓"))
|
add(CatalogItem("Porc", "Boucherie", "🥓"))
|
||||||
add(CatalogItem("Jambon", "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("Bacon", "Boucherie", "🥓"))
|
||||||
add(CatalogItem("Saumon", "Boucherie", "🐟"))
|
add(CatalogItem("Saumon", "Boucherie", "🐟", emptyList(), listOf("Atlantique", "Pacifique", "Sockeye", "Fumé", "En Conserve")))
|
||||||
add(CatalogItem("Thon", "Boucherie", "🐟"))
|
add(CatalogItem("Thon", "Boucherie", "🐟"))
|
||||||
add(CatalogItem("Dinde", "Boucherie", "🦃"))
|
add(CatalogItem("Dinde", "Boucherie", "🦃"))
|
||||||
add(CatalogItem("Canard", "Boucherie", "🦆"))
|
add(CatalogItem("Canard", "Boucherie", "🦆"))
|
||||||
|
|||||||
@ -24,9 +24,13 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
@ -88,6 +93,8 @@ 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.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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
|
||||||
@ -139,6 +146,8 @@ fun ListDetailScreen(
|
|||||||
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||||
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
|
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
|
||||||
val selectedItemId by viewModel.selectedItemId.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()
|
val otherLists by viewModel.otherLists.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
@ -255,12 +264,17 @@ fun ListDetailScreen(
|
|||||||
bottomBar = {
|
bottomBar = {
|
||||||
BottomSearchBar(
|
BottomSearchBar(
|
||||||
query = searchQuery,
|
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,
|
onClear = viewModel::clearSearch,
|
||||||
|
onCancel = viewModel::cancelSearch,
|
||||||
onAddCustom = {
|
onAddCustom = {
|
||||||
if (searchQuery.isNotBlank()) {
|
if (searchQuery.isNotBlank()) viewModel.addCustomItem(searchQuery)
|
||||||
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(
|
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)
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
) {
|
) {
|
||||||
SuggestionPanel(
|
SuggestionPanel(
|
||||||
@ -897,10 +925,25 @@ private fun EmptyActiveCard() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun BottomSearchBar(
|
private fun BottomSearchBar(
|
||||||
query: String,
|
query: String,
|
||||||
|
isSearchActive: Boolean,
|
||||||
|
hasPendingItem: Boolean,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
|
onActivate: () -> Unit,
|
||||||
onClear: () -> Unit,
|
onClear: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
onAddCustom: () -> 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(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
tonalElevation = 4.dp
|
tonalElevation = 4.dp
|
||||||
@ -917,9 +960,11 @@ private fun BottomSearchBar(
|
|||||||
TextField(
|
TextField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = onQueryChange,
|
onValueChange = onQueryChange,
|
||||||
placeholder = { Text("J'ai besoin…") },
|
placeholder = { Text(placeholder) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusRequester(focusRequester),
|
||||||
shape = RoundedCornerShape(28.dp),
|
shape = RoundedCornerShape(28.dp),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
if (query.isNotEmpty()) {
|
if (query.isNotEmpty()) {
|
||||||
@ -936,8 +981,16 @@ private fun BottomSearchBar(
|
|||||||
disabledIndicatorColor = Color.Transparent
|
disabledIndicatorColor = Color.Transparent
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if (isSearchActive) {
|
||||||
|
TextButton(onClick = onCancel) {
|
||||||
|
Text("Annuler")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = onAddCustom,
|
onClick = {
|
||||||
|
onActivate()
|
||||||
|
runCatching { focusRequester.requestFocus() }
|
||||||
|
},
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
modifier = Modifier.size(48.dp)
|
modifier = Modifier.size(48.dp)
|
||||||
@ -946,6 +999,7 @@ private fun BottomSearchBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -957,78 +1011,246 @@ private fun SuggestionPanel(
|
|||||||
suggestions: List<ListDetailViewModel.Suggestion>,
|
suggestions: List<ListDetailViewModel.Suggestion>,
|
||||||
onPick: (ListDetailViewModel.Suggestion) -> Unit
|
onPick: (ListDetailViewModel.Suggestion) -> Unit
|
||||||
) {
|
) {
|
||||||
|
val tealColor = Color(0xFF26A69A)
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(max = 320.dp),
|
.heightIn(max = 340.dp),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
tonalElevation = 6.dp,
|
tonalElevation = 6.dp,
|
||||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
|
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
LazyVerticalGrid(
|
||||||
modifier = Modifier
|
columns = GridCells.Fixed(3),
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
.verticalScroll(rememberScrollState())
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(bottom = 8.dp)
|
||||||
) {
|
) {
|
||||||
suggestions.forEach { suggestion ->
|
items(suggestions) { suggestion ->
|
||||||
SuggestionRow(
|
SuggestionTile(
|
||||||
suggestion = suggestion,
|
suggestion = suggestion,
|
||||||
|
tealColor = tealColor,
|
||||||
onPick = { onPick(suggestion) }
|
onPick = { onPick(suggestion) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(72.dp)) // espace pour la barre de saisie
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionRow(
|
private fun SuggestionTile(
|
||||||
suggestion: ListDetailViewModel.Suggestion,
|
suggestion: ListDetailViewModel.Suggestion,
|
||||||
|
tealColor: Color,
|
||||||
onPick: () -> Unit
|
onPick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val (badge, badgeColor) = when (suggestion) {
|
val isCreate = suggestion is ListDetailViewModel.Suggestion.Create
|
||||||
is ListDetailViewModel.Suggestion.Active -> "Sur la liste" to MaterialTheme.colorScheme.tertiary
|
val isActive = suggestion is ListDetailViewModel.Suggestion.Active
|
||||||
is ListDetailViewModel.Suggestion.Recent -> "Recently Used" to LocalStatusColors.current.safe
|
val containerColor = when {
|
||||||
is ListDetailViewModel.Suggestion.Catalog -> suggestion.item.category to MaterialTheme.colorScheme.onSurfaceVariant
|
isCreate -> MaterialTheme.colorScheme.primaryContainer
|
||||||
is ListDetailViewModel.Suggestion.RoomCatalog -> (suggestion.categoryName ?: "Catalogue") to MaterialTheme.colorScheme.onSurfaceVariant
|
isActive -> MaterialTheme.colorScheme.tertiaryContainer
|
||||||
is ListDetailViewModel.Suggestion.Create -> "Créer" to MaterialTheme.colorScheme.primary
|
else -> tealColor.copy(alpha = 0.15f)
|
||||||
}
|
}
|
||||||
Row(
|
val contentColor = when {
|
||||||
|
isCreate -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
isActive -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
|
else -> tealColor
|
||||||
|
}
|
||||||
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.aspectRatio(1f)
|
||||||
.clickable(onClick = onPick)
|
.clickable(onClick = onPick),
|
||||||
.padding(vertical = 10.dp, horizontal = 8.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
colors = CardDefaults.cardColors(containerColor = containerColor)
|
||||||
) {
|
) {
|
||||||
Text(text = suggestion.emoji, style = MaterialTheme.typography.titleLarge)
|
Box(
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
modifier = Modifier
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
.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(
|
||||||
text = suggestion.label,
|
text = suggestion.label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
color = contentColor,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
text = badge,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = badgeColor
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (suggestion is ListDetailViewModel.Suggestion.Create) {
|
if (isCreate) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.AutoAwesome,
|
imageVector = Icons.Filled.AutoAwesome,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = contentColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.size(16.dp)
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
Icon(
|
}
|
||||||
imageVector = Icons.Filled.Add,
|
}
|
||||||
contentDescription = null,
|
}
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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 = "Détails de l'article pour ${pending.emoji} ${pending.name}",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,6 +75,22 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
val tag: String?
|
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<String>,
|
||||||
|
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.
|
* Suggestion affichée dans le panneau au-dessus de la barre de recherche.
|
||||||
* Peut être un article existant (catalogue / recently used / actif) ou la
|
* Peut être un article existant (catalogue / recently used / actif) ou la
|
||||||
@ -116,6 +132,14 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
private val _selectedItemId = MutableStateFlow<Long?>(null)
|
private val _selectedItemId = MutableStateFlow<Long?>(null)
|
||||||
val selectedItemId: StateFlow<Long?> = _selectedItemId
|
val selectedItemId: StateFlow<Long?> = _selectedItemId
|
||||||
|
|
||||||
|
/** Article en cours de paramétrage dans le panneau inline. */
|
||||||
|
private val _pendingItem = MutableStateFlow<PendingItem?>(null)
|
||||||
|
val pendingItem: StateFlow<PendingItem?> = _pendingItem
|
||||||
|
|
||||||
|
/** Vrai si la barre de recherche est active (clavier ouvert). */
|
||||||
|
private val _isSearchActive = MutableStateFlow(false)
|
||||||
|
val isSearchActive: StateFlow<Boolean> = _isSearchActive
|
||||||
|
|
||||||
/** Listes disponibles pour l'action "Déplacer l'article". */
|
/** Listes disponibles pour l'action "Déplacer l'article". */
|
||||||
val otherLists: StateFlow<List<ShoppingListEntity>> = getListsUseCase
|
val otherLists: StateFlow<List<ShoppingListEntity>> = getListsUseCase
|
||||||
.observeActive()
|
.observeActive()
|
||||||
@ -223,34 +247,127 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
_searchQuery.value = ""
|
_searchQuery.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Active le mode recherche (clavier visible). */
|
||||||
* Tap sur une suggestion : ajoute l'article à la liste active.
|
fun activateSearch() {
|
||||||
*/
|
_isSearchActive.value = true
|
||||||
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)
|
|
||||||
}
|
|
||||||
clearSearch()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ajoute un article du catalogue legacy à la liste active. */
|
/** Annule la recherche : ferme le clavier, vide la saisie et le panneau de détail. */
|
||||||
fun addCatalogItem(catalogItem: CatalogProvider.CatalogItem) {
|
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 :
|
||||||
|
* 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) {
|
||||||
viewModelScope.launch {
|
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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 item = manageListUseCase.getItems(listId)
|
||||||
|
.firstOrNull { it.id == pending.itemId } ?: return@launch
|
||||||
|
manageListUseCase.updateItem(item.copy(tag = tag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<String>()
|
||||||
|
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 listId = _listIdFlow.value
|
||||||
val existing = manageListUseCase.getItems(listId)
|
val existing = manageListUseCase.getItems(listId)
|
||||||
.firstOrNull { it.productName.equals(catalogItem.name, ignoreCase = true) }
|
.firstOrNull { it.productName.equals(catalogItem.name, ignoreCase = true) }
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (existing.isChecked) {
|
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||||
manageListUseCase.setItemChecked(existing.id, false)
|
return existing.id
|
||||||
}
|
}
|
||||||
return@launch
|
return manageListUseCase.addItem(
|
||||||
}
|
|
||||||
manageListUseCase.addItemToList(
|
|
||||||
listId,
|
|
||||||
ShoppingListItemEntity(
|
ShoppingListItemEntity(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
productName = catalogItem.name,
|
productName = catalogItem.name,
|
||||||
@ -258,25 +375,19 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Ajoute un article du catalogue Room à la liste active. */
|
private suspend fun addCatalogItemAndGetId(item: CatalogItemEntity): Long {
|
||||||
fun addCatalogItem(item: CatalogItemEntity) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val existing = manageListUseCase.getItems(listId)
|
val existing = manageListUseCase.getItems(listId)
|
||||||
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) }
|
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) }
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (existing.isChecked) {
|
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||||
manageListUseCase.setItemChecked(existing.id, false)
|
return existing.id
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
val categoryName = item.primaryCategoryId?.let { catId ->
|
val categoryName = item.primaryCategoryId?.let { catId ->
|
||||||
catalogRepository.getCategory(catId)?.name
|
catalogRepository.getCategory(catId)?.name
|
||||||
} ?: categoryEngine.detectCategory(item.name)
|
} ?: categoryEngine.detectCategory(item.name)
|
||||||
manageListUseCase.addItemToList(
|
return manageListUseCase.addItem(
|
||||||
listId,
|
|
||||||
ShoppingListItemEntity(
|
ShoppingListItemEntity(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
productName = item.name,
|
productName = item.name,
|
||||||
@ -285,30 +396,19 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Crée un *own item* à partir de la saisie utilisateur (avec quantité optionnelle). */
|
private suspend fun addCustomItemAndGetId(rawText: String): Long {
|
||||||
fun addCustomItem(rawText: String) {
|
val trimmed = rawText.trim().ifEmpty { return -1L }
|
||||||
val trimmed = rawText.trim()
|
|
||||||
if (trimmed.isEmpty()) return
|
|
||||||
val (quantity, name) = parseQuantityAndName(trimmed)
|
val (quantity, name) = parseQuantityAndName(trimmed)
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val existing = manageListUseCase.getItems(listId)
|
val existing = manageListUseCase.getItems(listId)
|
||||||
.firstOrNull { it.productName.equals(name, ignoreCase = true) }
|
.firstOrNull { it.productName.equals(name, ignoreCase = true) }
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (existing.isChecked) {
|
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||||
manageListUseCase.setItemChecked(existing.id, false)
|
return existing.id
|
||||||
}
|
|
||||||
if (quantity != null && existing.note != quantity) {
|
|
||||||
manageListUseCase.updateItem(existing.copy(note = quantity, isChecked = false))
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
val category = categoryEngine.detectCategory(name)
|
val category = categoryEngine.detectCategory(name)
|
||||||
manageListUseCase.addItemToList(
|
return manageListUseCase.addItem(
|
||||||
listId,
|
|
||||||
ShoppingListItemEntity(
|
ShoppingListItemEntity(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
productName = name,
|
productName = name,
|
||||||
@ -317,6 +417,32 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 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()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Crée un item avec photo et description. */
|
/** Crée un item avec photo et description. */
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
MAJOR=1
|
MAJOR=1
|
||||||
MINOR=24
|
MINOR=25
|
||||||
PATCH=0
|
PATCH=0
|
||||||
CODE=35
|
CODE=36
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user