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": [
|
||||
{
|
||||
"domainId": "grocery",
|
||||
@ -20,7 +20,8 @@
|
||||
"name": "Pomme",
|
||||
"emoji": "🍎",
|
||||
"aliases": "apple|apples|pommes",
|
||||
"tags": "fruit"
|
||||
"tags": "fruit",
|
||||
"variants": "Gala,Cortland,Honeycrisp,Granny Smith,Fuji,McIntosh"
|
||||
},
|
||||
{
|
||||
"itemId": "banane",
|
||||
@ -69,7 +70,8 @@
|
||||
"name": "Tomate",
|
||||
"emoji": "🍅",
|
||||
"aliases": "tomatoes",
|
||||
"tags": "legume"
|
||||
"tags": "legume",
|
||||
"variants": "Baby,Cherry,Diced,Roma,Sundried"
|
||||
},
|
||||
{
|
||||
"itemId": "salade",
|
||||
@ -83,7 +85,8 @@
|
||||
"name": "Carotte",
|
||||
"emoji": "🥕",
|
||||
"aliases": "carrots|carrotte",
|
||||
"tags": "legume"
|
||||
"tags": "legume",
|
||||
"variants": "Baby,Râpée,Bio"
|
||||
},
|
||||
{
|
||||
"itemId": "brocoli",
|
||||
@ -104,7 +107,8 @@
|
||||
"name": "Poivron",
|
||||
"emoji": "🫑",
|
||||
"aliases": "bell pepper|bell peppers",
|
||||
"tags": "legume"
|
||||
"tags": "legume",
|
||||
"variants": "Rouge,Vert,Jaune,Orange"
|
||||
},
|
||||
{
|
||||
"itemId": "avocat",
|
||||
@ -118,7 +122,8 @@
|
||||
"name": "Oignon",
|
||||
"emoji": "🧅",
|
||||
"aliases": "onions",
|
||||
"tags": "legume"
|
||||
"tags": "legume",
|
||||
"variants": "Blanc,Rouge,Vert,Espagnol"
|
||||
},
|
||||
{
|
||||
"itemId": "ail",
|
||||
@ -132,14 +137,16 @@
|
||||
"name": "Pomme de terre",
|
||||
"emoji": "🥔",
|
||||
"aliases": "patate|patates|potatoes",
|
||||
"tags": "legume"
|
||||
"tags": "legume",
|
||||
"variants": "Régulière,Grelot,Douce,Russet,Yukon Gold"
|
||||
},
|
||||
{
|
||||
"itemId": "champignon",
|
||||
"name": "Champignon",
|
||||
"emoji": "🍄",
|
||||
"aliases": "mushrooms|champignons",
|
||||
"tags": "legume"
|
||||
"tags": "legume",
|
||||
"variants": "Blanc,Portobello,Shiitake,Crimini"
|
||||
},
|
||||
{
|
||||
"itemId": "epinard",
|
||||
@ -1267,7 +1274,8 @@
|
||||
"name": "Lait",
|
||||
"emoji": "🥛",
|
||||
"aliases": "milk",
|
||||
"tags": "laitier"
|
||||
"tags": "laitier",
|
||||
"variants": "Entier,2%,1%,Écrémé,Sans Lactose,Avoine,Amande,Soya"
|
||||
},
|
||||
{
|
||||
"itemId": "lait_amande",
|
||||
@ -1324,7 +1332,8 @@
|
||||
"name": "Yaourt",
|
||||
"emoji": "🥣",
|
||||
"aliases": "yogurt",
|
||||
"tags": "laitier"
|
||||
"tags": "laitier",
|
||||
"variants": "Grec,Nature,Vanille,Fraise,Sans Lactose"
|
||||
},
|
||||
{
|
||||
"itemId": "yaourt_grec",
|
||||
@ -1337,7 +1346,8 @@
|
||||
"name": "Œufs",
|
||||
"emoji": "🥚",
|
||||
"aliases": "eggs",
|
||||
"tags": "laitier"
|
||||
"tags": "laitier",
|
||||
"variants": "Gros,Très Gros,Moyen,Bio,Libre Parcours"
|
||||
},
|
||||
{
|
||||
"itemId": "beurre_sans_lactose",
|
||||
|
||||
@ -32,7 +32,7 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity
|
||||
CatalogItemEntity::class,
|
||||
ItemCategoryCrossRef::class
|
||||
],
|
||||
version = 8,
|
||||
version = 9,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
||||
@ -69,6 +69,7 @@ data class CatalogItemEntity(
|
||||
val barcode: String? = null,
|
||||
val aliases: String = "",
|
||||
val tags: String = "",
|
||||
val variants: String = "",
|
||||
val isUserCreated: Boolean = false,
|
||||
val popularity: Int = 0,
|
||||
val sortOrder: Int = 0
|
||||
|
||||
@ -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,
|
||||
aliases = itemSeed.aliases.orEmpty(),
|
||||
tags = itemSeed.tags.orEmpty(),
|
||||
variants = itemSeed.variants.orEmpty(),
|
||||
barcode = itemSeed.barcode,
|
||||
sortOrder = index
|
||||
)
|
||||
|
||||
@ -40,5 +40,6 @@ data class ItemSeed(
|
||||
val emoji: String,
|
||||
val aliases: String? = null,
|
||||
val tags: String? = null,
|
||||
val variants: String? = null,
|
||||
val barcode: String? = null
|
||||
)
|
||||
|
||||
@ -9,6 +9,7 @@ import com.safebite.app.data.local.database.dao.ScanHistoryDao
|
||||
import com.safebite.app.data.local.database.dao.ShoppingListDao
|
||||
import com.safebite.app.data.local.database.dao.UserProfileDao
|
||||
import com.safebite.app.data.local.database.migration.MIGRATION_7_8
|
||||
import com.safebite.app.data.local.database.migration.MIGRATION_8_9
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@ -24,7 +25,7 @@ object DatabaseModule {
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): SafeBiteDatabase =
|
||||
Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME)
|
||||
.addMigrations(MIGRATION_7_8)
|
||||
.addMigrations(MIGRATION_7_8, MIGRATION_8_9)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
|
||||
@ -19,7 +19,8 @@ class CatalogProvider @Inject constructor() {
|
||||
val name: String,
|
||||
val category: String,
|
||||
val emoji: String,
|
||||
val aliases: List<String> = emptyList()
|
||||
val aliases: List<String> = emptyList(),
|
||||
val variants: List<String> = emptyList()
|
||||
) {
|
||||
fun matches(query: String): Boolean {
|
||||
val q = query.trim().lowercase()
|
||||
@ -50,24 +51,24 @@ class CatalogProvider @Inject constructor() {
|
||||
/** Liste plate du catalogue. */
|
||||
val items: List<CatalogItem> = buildList {
|
||||
// Fruits & Légumes
|
||||
add(CatalogItem("Pomme", "Fruits & Légumes", "🍎", listOf("apple", "apples", "pommes")))
|
||||
add(CatalogItem("Pomme", "Fruits & Légumes", "🍎", listOf("apple", "apples", "pommes"), listOf("Gala", "Cortland", "Honeycrisp", "Granny Smith", "Fuji", "McIntosh")))
|
||||
add(CatalogItem("Banane", "Fruits & Légumes", "🍌", listOf("banana", "bananas")))
|
||||
add(CatalogItem("Orange", "Fruits & Légumes", "🍊", listOf("oranges")))
|
||||
add(CatalogItem("Citron", "Fruits & Légumes", "🍋", listOf("lemon")))
|
||||
add(CatalogItem("Fraise", "Fruits & Légumes", "🍓", listOf("strawberry", "strawberries")))
|
||||
add(CatalogItem("Raisin", "Fruits & Légumes", "🍇", listOf("grapes")))
|
||||
add(CatalogItem("Raisin", "Fruits & Légumes", "🍇", listOf("grapes"), listOf("Vert", "Rouge", "Sans pépins")))
|
||||
add(CatalogItem("Poire", "Fruits & Légumes", "🍐", listOf("pear", "pears", "poires")))
|
||||
add(CatalogItem("Tomate", "Fruits & Légumes", "🍅", listOf("tomatoes")))
|
||||
add(CatalogItem("Tomate", "Fruits & Légumes", "🍅", listOf("tomatoes"), listOf("Baby", "Cherry", "Diced", "Roma", "Sundried")))
|
||||
add(CatalogItem("Salade", "Fruits & Légumes", "🥬", listOf("lettuce", "salad")))
|
||||
add(CatalogItem("Carotte", "Fruits & Légumes", "🥕", listOf("carrots", "carrotte")))
|
||||
add(CatalogItem("Carotte", "Fruits & Légumes", "🥕", listOf("carrots", "carrotte"), listOf("Baby", "Râpée", "Bio")))
|
||||
add(CatalogItem("Brocoli", "Fruits & Légumes", "🥦", listOf("broccoli")))
|
||||
add(CatalogItem("Concombre", "Fruits & Légumes", "🥒", listOf("cucumber")))
|
||||
add(CatalogItem("Poivron", "Fruits & Légumes", "🫑", listOf("bell pepper", "bell peppers")))
|
||||
add(CatalogItem("Poivron", "Fruits & Légumes", "🫑", listOf("bell pepper", "bell peppers"), listOf("Rouge", "Vert", "Jaune", "Orange")))
|
||||
add(CatalogItem("Avocat", "Fruits & Légumes", "🥑", listOf("avocado")))
|
||||
add(CatalogItem("Oignon", "Fruits & Légumes", "🧅", listOf("onions")))
|
||||
add(CatalogItem("Oignon", "Fruits & Légumes", "🧅", listOf("onions"), listOf("Blanc", "Rouge", "Vert", "Espagnol", "Vidalia")))
|
||||
add(CatalogItem("Ail", "Fruits & Légumes", "🧄", listOf("garlic")))
|
||||
add(CatalogItem("Pomme de terre", "Fruits & Légumes", "🥔", listOf("patate", "patates", "potatoes")))
|
||||
add(CatalogItem("Champignon", "Fruits & Légumes", "🍄", listOf("mushrooms", "champignons")))
|
||||
add(CatalogItem("Pomme de terre", "Fruits & Légumes", "🥔", listOf("patate", "patates", "potatoes"), listOf("Régulière", "Grelot", "Douce", "Russet", "Yukon Gold")))
|
||||
add(CatalogItem("Champignon", "Fruits & Légumes", "🍄", listOf("mushrooms", "champignons"), listOf("Blanc", "Portobello", "Shiitake", "Crimini")))
|
||||
add(CatalogItem("Épinard", "Fruits & Légumes", "🥬", listOf("spinach")))
|
||||
add(CatalogItem("Ananas", "Fruits & Légumes", "🍍", listOf("pineapple")))
|
||||
add(CatalogItem("Pêche", "Fruits & Légumes", "🍑", listOf("peach")))
|
||||
@ -79,7 +80,7 @@ class CatalogProvider @Inject constructor() {
|
||||
add(CatalogItem("Noix de coco", "Fruits & Légumes", "🥥", listOf("coconut")))
|
||||
add(CatalogItem("Aubergine", "Fruits & Légumes", "🍆", listOf("eggplant")))
|
||||
add(CatalogItem("Maïs", "Fruits & Légumes", "🌽", listOf("sweet corn", "corncobs", "corn cobs")))
|
||||
add(CatalogItem("Piment", "Fruits & Légumes", "🌶️", listOf("chillies", "chili", "piment jaune")))
|
||||
add(CatalogItem("Piment", "Fruits & Légumes", "🌶️", listOf("chillies", "chili", "piment jaune"), listOf("Jalapeño", "Serrano", "Habanero", "Chipotle")))
|
||||
add(CatalogItem("Courgette", "Fruits & Légumes", "🥒", listOf("zucchini")))
|
||||
add(CatalogItem("Chou-fleur", "Fruits & Légumes", "🥦", listOf("cauliflower")))
|
||||
add(CatalogItem("Chou", "Fruits & Légumes", "🥬", listOf("cabbage")))
|
||||
@ -176,7 +177,7 @@ class CatalogProvider @Inject constructor() {
|
||||
add(CatalogItem("Baies", "Fruits & Légumes", "🫐", listOf("berries")))
|
||||
|
||||
// Boulangerie
|
||||
add(CatalogItem("Pain", "Boulangerie", "🍞", listOf("baguette")))
|
||||
add(CatalogItem("Pain", "Boulangerie", "🍞", listOf("baguette"), listOf("Blanc", "Brun", "Complet", "Sans Gluten", "Multigrains")))
|
||||
add(CatalogItem("Baguette", "Boulangerie", "🥖"))
|
||||
add(CatalogItem("Croissant", "Boulangerie", "🥐"))
|
||||
add(CatalogItem("Brioche", "Boulangerie", "🥯"))
|
||||
@ -244,12 +245,12 @@ class CatalogProvider @Inject constructor() {
|
||||
add(CatalogItem("Waffles", "Boulangerie", "🧇"))
|
||||
|
||||
// Produits laitiers
|
||||
add(CatalogItem("Lait", "Produits laitiers", "🥛", listOf("milk")))
|
||||
add(CatalogItem("Yaourt", "Produits laitiers", "🥣", listOf("yogurt")))
|
||||
add(CatalogItem("Lait", "Produits laitiers", "🥛", listOf("milk"), listOf("Entier", "2%", "1%", "Écrémé", "Sans Lactose", "Avoine", "Amande", "Soya")))
|
||||
add(CatalogItem("Yaourt", "Produits laitiers", "🥣", listOf("yogurt"), listOf("Grec", "Nature", "Vaniille", "Fraise", "Sans Lactose")))
|
||||
add(CatalogItem("Beurre", "Produits laitiers", "🧈"))
|
||||
add(CatalogItem("Fromage", "Produits laitiers", "🧀", listOf("cheese")))
|
||||
add(CatalogItem("Fromage", "Produits laitiers", "🧀", listOf("cheese"), listOf("Cheddar", "Mozzarella", "Parmésan", "Feta", "Brie", "Emmental", "Chèvre")))
|
||||
add(CatalogItem("Crème fraîche", "Produits laitiers", "🥛"))
|
||||
add(CatalogItem("Œufs", "Produits laitiers", "🥚", listOf("oeufs", "eggs")))
|
||||
add(CatalogItem("Œufs", "Produits laitiers", "🥚", listOf("oeufs", "eggs"), listOf("Gros", "Très Gros", "Moyen", "Bio", "Libre Parcours")))
|
||||
add(CatalogItem("Mozzarella", "Produits laitiers", "🧀"))
|
||||
add(CatalogItem("Parmesan", "Produits laitiers", "🧀"))
|
||||
add(CatalogItem("Cheddar", "Produits laitiers", "🧀"))
|
||||
@ -324,14 +325,14 @@ class CatalogProvider @Inject constructor() {
|
||||
add(CatalogItem("Yogourt Sans Lactose", "Produits laitiers", "🥣"))
|
||||
|
||||
// Boucherie
|
||||
add(CatalogItem("Poulet", "Boucherie", "🍗"))
|
||||
add(CatalogItem("Poulet", "Boucherie", "🍗", emptyList(), listOf("Poitrine", "Cuisse", "Aile", "Entier", "Haché")))
|
||||
add(CatalogItem("Bœuf haché", "Boucherie", "🥩", listOf("beef")))
|
||||
add(CatalogItem("Steak", "Boucherie", "🥩"))
|
||||
add(CatalogItem("Porc", "Boucherie", "🥓"))
|
||||
add(CatalogItem("Jambon", "Boucherie", "🥓"))
|
||||
add(CatalogItem("Saucisse", "Boucherie", "🌭"))
|
||||
add(CatalogItem("Saucisse", "Boucherie", "🌭", emptyList(), listOf("Porc", "Veau", "Dinde", "Italienne", "Cocktail")))
|
||||
add(CatalogItem("Bacon", "Boucherie", "🥓"))
|
||||
add(CatalogItem("Saumon", "Boucherie", "🐟"))
|
||||
add(CatalogItem("Saumon", "Boucherie", "🐟", emptyList(), listOf("Atlantique", "Pacifique", "Sockeye", "Fumé", "En Conserve")))
|
||||
add(CatalogItem("Thon", "Boucherie", "🐟"))
|
||||
add(CatalogItem("Dinde", "Boucherie", "🦃"))
|
||||
add(CatalogItem("Canard", "Boucherie", "🦆"))
|
||||
|
||||
@ -24,9 +24,13 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
@ -77,6 +81,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
@ -88,6 +93,8 @@ import java.io.File
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@ -139,6 +146,8 @@ fun ListDetailScreen(
|
||||
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
|
||||
val selectedItemId by viewModel.selectedItemId.collectAsStateWithLifecycle()
|
||||
val pendingItem by viewModel.pendingItem.collectAsStateWithLifecycle()
|
||||
val isSearchActive by viewModel.isSearchActive.collectAsStateWithLifecycle()
|
||||
val otherLists by viewModel.otherLists.collectAsStateWithLifecycle()
|
||||
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
@ -255,12 +264,17 @@ fun ListDetailScreen(
|
||||
bottomBar = {
|
||||
BottomSearchBar(
|
||||
query = searchQuery,
|
||||
onQueryChange = viewModel::updateSearchQuery,
|
||||
isSearchActive = isSearchActive || pendingItem != null,
|
||||
hasPendingItem = pendingItem != null,
|
||||
onQueryChange = { q ->
|
||||
if (pendingItem != null) viewModel.closePendingItem()
|
||||
viewModel.updateSearchQuery(q)
|
||||
},
|
||||
onActivate = viewModel::activateSearch,
|
||||
onClear = viewModel::clearSearch,
|
||||
onCancel = viewModel::cancelSearch,
|
||||
onAddCustom = {
|
||||
if (searchQuery.isNotBlank()) {
|
||||
viewModel.addCustomItem(searchQuery)
|
||||
}
|
||||
if (searchQuery.isNotBlank()) viewModel.addCustomItem(searchQuery)
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -304,9 +318,23 @@ fun ListDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay des suggestions (au-dessus du contenu, sous la barre de saisie).
|
||||
// Overlay : panneau de détail inline (après sélection) ou suggestions.
|
||||
AnimatedVisibility(
|
||||
visible = suggestions.isNotEmpty(),
|
||||
visible = pendingItem != null,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
) {
|
||||
pendingItem?.let {
|
||||
ItemDetailPanel(
|
||||
pending = it,
|
||||
onQuantity = viewModel::updatePendingQuantity,
|
||||
onVariant = viewModel::updatePendingVariant,
|
||||
onTag = viewModel::updatePendingTag,
|
||||
onNote = viewModel::updatePendingNote
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = pendingItem == null && suggestions.isNotEmpty(),
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
) {
|
||||
SuggestionPanel(
|
||||
@ -897,10 +925,25 @@ private fun EmptyActiveCard() {
|
||||
@Composable
|
||||
private fun BottomSearchBar(
|
||||
query: String,
|
||||
isSearchActive: Boolean,
|
||||
hasPendingItem: Boolean,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onActivate: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onAddCustom: () -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(isSearchActive) {
|
||||
if (isSearchActive) {
|
||||
runCatching { focusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
val placeholder = when {
|
||||
hasPendingItem -> "Article suivant…"
|
||||
isSearchActive -> "p. ex. pain"
|
||||
else -> "J'ai besoin…"
|
||||
}
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 4.dp
|
||||
@ -917,9 +960,11 @@ private fun BottomSearchBar(
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("J'ai besoin…") },
|
||||
placeholder = { Text(placeholder) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
@ -936,8 +981,16 @@ private fun BottomSearchBar(
|
||||
disabledIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
if (isSearchActive) {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text("Annuler")
|
||||
}
|
||||
} else {
|
||||
FloatingActionButton(
|
||||
onClick = onAddCustom,
|
||||
onClick = {
|
||||
onActivate()
|
||||
runCatching { focusRequester.requestFocus() }
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
@ -946,6 +999,7 @@ private fun BottomSearchBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -957,78 +1011,246 @@ private fun SuggestionPanel(
|
||||
suggestions: List<ListDetailViewModel.Suggestion>,
|
||||
onPick: (ListDetailViewModel.Suggestion) -> Unit
|
||||
) {
|
||||
val tealColor = Color(0xFF26A69A)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 320.dp),
|
||||
.heightIn(max = 340.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(bottom = 8.dp)
|
||||
) {
|
||||
suggestions.forEach { suggestion ->
|
||||
SuggestionRow(
|
||||
items(suggestions) { suggestion ->
|
||||
SuggestionTile(
|
||||
suggestion = suggestion,
|
||||
tealColor = tealColor,
|
||||
onPick = { onPick(suggestion) }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(72.dp)) // espace pour la barre de saisie
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuggestionRow(
|
||||
private fun SuggestionTile(
|
||||
suggestion: ListDetailViewModel.Suggestion,
|
||||
tealColor: Color,
|
||||
onPick: () -> Unit
|
||||
) {
|
||||
val (badge, badgeColor) = when (suggestion) {
|
||||
is ListDetailViewModel.Suggestion.Active -> "Sur la liste" to MaterialTheme.colorScheme.tertiary
|
||||
is ListDetailViewModel.Suggestion.Recent -> "Recently Used" to LocalStatusColors.current.safe
|
||||
is ListDetailViewModel.Suggestion.Catalog -> suggestion.item.category to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
is ListDetailViewModel.Suggestion.RoomCatalog -> (suggestion.categoryName ?: "Catalogue") to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
is ListDetailViewModel.Suggestion.Create -> "Créer" to MaterialTheme.colorScheme.primary
|
||||
val isCreate = suggestion is ListDetailViewModel.Suggestion.Create
|
||||
val isActive = suggestion is ListDetailViewModel.Suggestion.Active
|
||||
val containerColor = when {
|
||||
isCreate -> MaterialTheme.colorScheme.primaryContainer
|
||||
isActive -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
else -> tealColor.copy(alpha = 0.15f)
|
||||
}
|
||||
Row(
|
||||
val contentColor = when {
|
||||
isCreate -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
isActive -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
else -> tealColor
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onPick)
|
||||
.padding(vertical = 10.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.aspectRatio(1f)
|
||||
.clickable(onClick = onPick),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor)
|
||||
) {
|
||||
Text(text = suggestion.emoji, style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
text = suggestion.emoji,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = suggestion.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = contentColor,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = badge,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = badgeColor
|
||||
)
|
||||
}
|
||||
if (suggestion is ListDetailViewModel.Suggestion.Create) {
|
||||
if (isCreate) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.AutoAwesome,
|
||||
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?
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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)
|
||||
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". */
|
||||
val otherLists: StateFlow<List<ShoppingListEntity>> = getListsUseCase
|
||||
.observeActive()
|
||||
@ -223,34 +247,127 @@ class ListDetailViewModel @Inject constructor(
|
||||
_searchQuery.value = ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap sur une suggestion : ajoute l'article à la liste active.
|
||||
*/
|
||||
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()
|
||||
/** Active le mode recherche (clavier visible). */
|
||||
fun activateSearch() {
|
||||
_isSearchActive.value = true
|
||||
}
|
||||
|
||||
/** Ajoute un article du catalogue legacy à la liste active. */
|
||||
fun addCatalogItem(catalogItem: CatalogProvider.CatalogItem) {
|
||||
/** 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 :
|
||||
* 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 {
|
||||
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 existing = manageListUseCase.getItems(listId)
|
||||
.firstOrNull { it.productName.equals(catalogItem.name, ignoreCase = true) }
|
||||
if (existing != null) {
|
||||
if (existing.isChecked) {
|
||||
manageListUseCase.setItemChecked(existing.id, false)
|
||||
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||
return existing.id
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
manageListUseCase.addItemToList(
|
||||
listId,
|
||||
return manageListUseCase.addItem(
|
||||
ShoppingListItemEntity(
|
||||
listId = listId,
|
||||
productName = catalogItem.name,
|
||||
@ -258,25 +375,19 @@ class ListDetailViewModel @Inject constructor(
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ajoute un article du catalogue Room à la liste active. */
|
||||
fun addCatalogItem(item: CatalogItemEntity) {
|
||||
viewModelScope.launch {
|
||||
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@launch
|
||||
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)
|
||||
manageListUseCase.addItemToList(
|
||||
listId,
|
||||
return manageListUseCase.addItem(
|
||||
ShoppingListItemEntity(
|
||||
listId = listId,
|
||||
productName = item.name,
|
||||
@ -285,30 +396,19 @@ class ListDetailViewModel @Inject constructor(
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
private suspend fun addCustomItemAndGetId(rawText: String): Long {
|
||||
val trimmed = rawText.trim().ifEmpty { return -1L }
|
||||
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
|
||||
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||
return existing.id
|
||||
}
|
||||
val category = categoryEngine.detectCategory(name)
|
||||
manageListUseCase.addItemToList(
|
||||
listId,
|
||||
return manageListUseCase.addItem(
|
||||
ShoppingListItemEntity(
|
||||
listId = listId,
|
||||
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. */
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
MAJOR=1
|
||||
MINOR=24
|
||||
MINOR=25
|
||||
PATCH=0
|
||||
CODE=35
|
||||
CODE=36
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user