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:
Bruno Charest 2026-04-30 09:29:38 -04:00
parent 49eafca209
commit 9e021397b7
11 changed files with 550 additions and 171 deletions

View File

@ -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",

View File

@ -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)

View File

@ -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

View File

@ -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 ''"
)
}
}

View File

@ -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
) )

View File

@ -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
) )

View File

@ -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()

View File

@ -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", "🦆"))

View File

@ -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,13 +981,22 @@ private fun BottomSearchBar(
disabledIndicatorColor = Color.Transparent disabledIndicatorColor = Color.Transparent
) )
) )
FloatingActionButton( if (isSearchActive) {
onClick = onAddCustom, TextButton(onClick = onCancel) {
containerColor = MaterialTheme.colorScheme.primary, Text("Annuler")
contentColor = MaterialTheme.colorScheme.onPrimary, }
modifier = Modifier.size(48.dp) } else {
) { FloatingActionButton(
Icon(Icons.Filled.Add, contentDescription = "Ajouter") 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<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 = 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(
text = suggestion.label, text = "Détails de l'article pour ${pending.emoji} ${pending.name}",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text(
text = badge, // Quantité 1-5
style = MaterialTheme.typography.labelSmall, Row(
color = badgeColor horizontalArrangement = Arrangement.spacedBy(8.dp)
) ) {
} (1..5).forEach { qty ->
if (suggestion is ListDetailViewModel.Suggestion.Create) { val selected = pending.selectedQuantity == qty
Icon( Surface(
imageVector = Icons.Filled.AutoAwesome, modifier = Modifier
contentDescription = null, .size(44.dp)
tint = MaterialTheme.colorScheme.primary .clip(CircleShape)
) .clickable { onQuantity(if (selected) null else qty) },
} else { color = if (selected) selectedColor else idleContainer,
Icon( shape = CircleShape
imageVector = Icons.Filled.Add, ) {
contentDescription = null, Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
tint = MaterialTheme.colorScheme.onSurfaceVariant 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)
)
}
} }
} }
} }

View File

@ -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,98 +247,200 @@ class ListDetailViewModel @Inject constructor(
_searchQuery.value = "" _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 larticle à 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) { fun applySuggestion(suggestion: Suggestion) {
when (suggestion) { viewModelScope.launch {
is Suggestion.Catalog -> addCatalogItem(suggestion.item) val itemId = when (suggestion) {
is Suggestion.RoomCatalog -> addCatalogItem(suggestion.item) is Suggestion.Catalog -> addCatalogItemAndGetId(suggestion.item)
is Suggestion.Recent -> restoreItem(suggestion.item.id) is Suggestion.RoomCatalog -> addCatalogItemAndGetId(suggestion.item)
is Suggestion.Active -> { /* déjà sur la liste, ne fait rien */ } is Suggestion.Recent -> {
is Suggestion.Create -> addCustomItem(suggestion.rawText) 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. */ /** Met à jour la quantité sélectionnée et sauvegarde en DB. */
fun addCatalogItem(catalogItem: CatalogProvider.CatalogItem) { 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 { viewModelScope.launch {
val listId = _listIdFlow.value val listId = _listIdFlow.value
val existing = manageListUseCase.getItems(listId) val item = manageListUseCase.getItems(listId)
.firstOrNull { it.productName.equals(catalogItem.name, ignoreCase = true) } .firstOrNull { it.id == pending.itemId } ?: return@launch
if (existing != null) { manageListUseCase.updateItem(item.copy(tag = tag))
if (existing.isChecked) {
manageListUseCase.setItemChecked(existing.id, false)
}
return@launch
}
manageListUseCase.addItemToList(
listId,
ShoppingListItemEntity(
listId = listId,
productName = catalogItem.name,
category = catalogItem.category
)
)
} }
} }
/** 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<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 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) { fun addCatalogItem(item: CatalogItemEntity) {
viewModelScope.launch { viewModelScope.launch {
val listId = _listIdFlow.value val itemId = addCatalogItemAndGetId(item)
val existing = manageListUseCase.getItems(listId) val variants = item.variants.split(",")
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) } .map { it.trim() }.filter { it.isNotEmpty() }
if (existing != null) { val existingItem = manageListUseCase.getItems(_listIdFlow.value)
if (existing.isChecked) { .firstOrNull { it.id == itemId }
manageListUseCase.setItemChecked(existing.id, false) _pendingItem.value = PendingItem(
} itemId = itemId,
return@launch name = item.name,
} emoji = item.emoji,
val categoryName = item.primaryCategoryId?.let { catId -> variants = variants,
catalogRepository.getCategory(catId)?.name selectedTag = existingItem?.tag,
} ?: categoryEngine.detectCategory(item.name) note = existingItem?.note.orEmpty()
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
)
) )
} }
} }

View File

@ -1,4 +1,4 @@
MAJOR=1 MAJOR=1
MINOR=24 MINOR=25
PATCH=0 PATCH=0
CODE=35 CODE=36