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": [
{
"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",

View File

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

View File

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

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,
aliases = itemSeed.aliases.orEmpty(),
tags = itemSeed.tags.orEmpty(),
variants = itemSeed.variants.orEmpty(),
barcode = itemSeed.barcode,
sortOrder = index
)

View File

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

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

View File

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

View File

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

View File

@ -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 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) {
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. */

View File

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