From 1656b189f46aef27ff60af562ff5623a0a552454 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 26 Apr 2026 12:03:17 -0400 Subject: [PATCH] feat: redesign shopping list detail screen with Bring!-style interface, add item notes field, and implement bottom sheet for item management - Add `note` field to `ShoppingListItemEntity` for quantity/description tracking - Redesign `ListDetailScreen` with vertical hierarchy: active items, recently used section, catalog categories - Replace swipe gestures with tap (mark bought/restore) and long-press (details sheet) interactions - Implement `ItemDetailSheet` modal bottom sheet for editing notes, --- .../data/local/database/entity/Entities.kt | 1 + .../app/domain/engine/CatalogProvider.kt | 223 +++ .../screen/lists/ListDetailScreen.kt | 1489 ++++++++++------- .../screen/lists/ListDetailViewModel.kt | 436 +++-- version.properties | 4 +- 5 files changed, 1405 insertions(+), 748 deletions(-) create mode 100644 app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt diff --git a/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt index 2ccbdb4..046db99 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt @@ -97,5 +97,6 @@ data class ShoppingListItemEntity( val category: String? = null, // "Frais", "Épicerie", etc. val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER" val allergenWarning: String? = null, // Allergène détecté pour alerte + val note: String? = null, // Quantité / description libre (ex: "2 kg") val addedAt: Long = System.currentTimeMillis() ) diff --git a/app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt b/app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt new file mode 100644 index 0000000..825cf50 --- /dev/null +++ b/app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt @@ -0,0 +1,223 @@ +package com.safebite.app.domain.engine + +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Catalogue statique d'articles courants, organisé par rayon (à la Bring!). + * + * Sert à afficher les sections par catégorie dans l'écran de liste : l'utilisateur + * peut ainsi parcourir et taper sur une tuile pour ajouter un article rapidement. + * + * Les catégories et noms sont alignés avec [CategoryEngine] pour garantir la + * cohérence de la classification automatique. + */ +@Singleton +class CatalogProvider @Inject constructor() { + + data class CatalogItem( + val name: String, + val category: String, + val emoji: String, + val aliases: List = emptyList() + ) { + fun matches(query: String): Boolean { + val q = query.trim().lowercase() + if (q.isEmpty()) return true + if (name.lowercase().contains(q)) return true + return aliases.any { it.lowercase().contains(q) } + } + } + + /** Toutes les sections catalogue, dans l'ordre d'affichage. */ + val categories: List = listOf( + "Fruits & Légumes", + "Boulangerie", + "Produits laitiers", + "Boucherie", + "Épicerie", + "Boissons", + "Surgelés", + "Hygiène", + "Entretien", + "Bébé", + "Animaux" + ) + + /** Liste plate du catalogue. */ + val items: List = buildList { + // Fruits & Légumes + add(CatalogItem("Pomme", "Fruits & Légumes", "🍎", listOf("apple"))) + add(CatalogItem("Banane", "Fruits & Légumes", "🍌", listOf("banana"))) + add(CatalogItem("Orange", "Fruits & Légumes", "🍊")) + add(CatalogItem("Citron", "Fruits & Légumes", "🍋")) + add(CatalogItem("Fraise", "Fruits & Légumes", "🍓", listOf("strawberry"))) + add(CatalogItem("Raisin", "Fruits & Légumes", "🍇")) + add(CatalogItem("Poire", "Fruits & Légumes", "🍐")) + add(CatalogItem("Tomate", "Fruits & Légumes", "🍅")) + add(CatalogItem("Salade", "Fruits & Légumes", "🥬")) + add(CatalogItem("Carotte", "Fruits & Légumes", "🥕")) + add(CatalogItem("Brocoli", "Fruits & Légumes", "🥦")) + add(CatalogItem("Concombre", "Fruits & Légumes", "🥒")) + add(CatalogItem("Poivron", "Fruits & Légumes", "🫑")) + add(CatalogItem("Avocat", "Fruits & Légumes", "🥑")) + add(CatalogItem("Oignon", "Fruits & Légumes", "🧅")) + add(CatalogItem("Ail", "Fruits & Légumes", "🧄")) + add(CatalogItem("Pomme de terre", "Fruits & Légumes", "🥔", listOf("patate"))) + add(CatalogItem("Champignon", "Fruits & Légumes", "🍄")) + add(CatalogItem("Épinard", "Fruits & Légumes", "🥬")) + + // Boulangerie + add(CatalogItem("Pain", "Boulangerie", "🍞", listOf("baguette"))) + add(CatalogItem("Baguette", "Boulangerie", "🥖")) + add(CatalogItem("Croissant", "Boulangerie", "🥐")) + add(CatalogItem("Brioche", "Boulangerie", "🥯")) + add(CatalogItem("Pain de mie", "Boulangerie", "🍞")) + add(CatalogItem("Biscotte", "Boulangerie", "🍞")) + add(CatalogItem("Tortillas", "Boulangerie", "🌯")) + + // Produits laitiers + add(CatalogItem("Lait", "Produits laitiers", "🥛", listOf("milk"))) + add(CatalogItem("Yaourt", "Produits laitiers", "🥣", listOf("yogurt"))) + add(CatalogItem("Beurre", "Produits laitiers", "🧈")) + add(CatalogItem("Fromage", "Produits laitiers", "🧀", listOf("cheese"))) + add(CatalogItem("Crème", "Produits laitiers", "🥛")) + add(CatalogItem("Œufs", "Produits laitiers", "🥚", listOf("oeufs", "eggs"))) + add(CatalogItem("Mozzarella", "Produits laitiers", "🧀")) + add(CatalogItem("Parmesan", "Produits laitiers", "🧀")) + + // Boucherie + add(CatalogItem("Poulet", "Boucherie", "🍗")) + 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("Bacon", "Boucherie", "🥓")) + add(CatalogItem("Saumon", "Boucherie", "🐟")) + add(CatalogItem("Thon", "Boucherie", "🐟")) + + // Épicerie + add(CatalogItem("Riz", "Épicerie", "🍚")) + add(CatalogItem("Pâtes", "Épicerie", "🍝", listOf("spaghetti"))) + add(CatalogItem("Spaghetti", "Épicerie", "🍝")) + add(CatalogItem("Farine", "Épicerie", "🌾")) + add(CatalogItem("Sucre", "Épicerie", "🍬")) + add(CatalogItem("Sel", "Épicerie", "🧂")) + add(CatalogItem("Huile d'olive", "Épicerie", "🫒")) + add(CatalogItem("Vinaigre", "Épicerie", "🧴")) + add(CatalogItem("Moutarde", "Épicerie", "🟡")) + add(CatalogItem("Ketchup", "Épicerie", "🍅")) + add(CatalogItem("Mayonnaise", "Épicerie", "🥚")) + add(CatalogItem("Confiture", "Épicerie", "🍓")) + add(CatalogItem("Miel", "Épicerie", "🍯")) + add(CatalogItem("Chocolat", "Épicerie", "🍫")) + add(CatalogItem("Biscuits", "Épicerie", "🍪")) + add(CatalogItem("Céréales", "Épicerie", "🥣", listOf("muesli"))) + add(CatalogItem("Lentilles", "Épicerie", "🫘")) + add(CatalogItem("Pois chiches", "Épicerie", "🫘")) + add(CatalogItem("Conserves", "Épicerie", "🥫")) + add(CatalogItem("Soupe", "Épicerie", "🍲")) + + // Boissons + add(CatalogItem("Eau", "Boissons", "💧")) + add(CatalogItem("Jus d'orange", "Boissons", "🧃")) + add(CatalogItem("Café", "Boissons", "☕")) + add(CatalogItem("Thé", "Boissons", "🍵")) + add(CatalogItem("Vin", "Boissons", "🍷")) + add(CatalogItem("Bière", "Boissons", "🍺")) + add(CatalogItem("Soda", "Boissons", "🥤")) + add(CatalogItem("Limonade", "Boissons", "🍋")) + + // Surgelés + add(CatalogItem("Pizza surgelée", "Surgelés", "🍕")) + add(CatalogItem("Frites surgelées", "Surgelés", "🍟")) + add(CatalogItem("Glace", "Surgelés", "🍨")) + add(CatalogItem("Légumes surgelés", "Surgelés", "🥦")) + add(CatalogItem("Poisson surgelé", "Surgelés", "🐟")) + + // Hygiène + add(CatalogItem("Papier toilette", "Hygiène", "🧻", listOf("toilet paper"))) + add(CatalogItem("Mouchoirs", "Hygiène", "🤧")) + add(CatalogItem("Dentifrice", "Hygiène", "🦷")) + add(CatalogItem("Shampoing", "Hygiène", "🧴")) + add(CatalogItem("Savon", "Hygiène", "🧼")) + add(CatalogItem("Gel douche", "Hygiène", "🧴")) + add(CatalogItem("Déodorant", "Hygiène", "🧴")) + + // Entretien + add(CatalogItem("Lessive", "Entretien", "🧺")) + add(CatalogItem("Liquide vaisselle", "Entretien", "🧴")) + add(CatalogItem("Éponge", "Entretien", "🧽")) + add(CatalogItem("Javel", "Entretien", "🧴")) + add(CatalogItem("Sacs poubelle", "Entretien", "🗑️")) + + // Bébé + add(CatalogItem("Couches", "Bébé", "👶")) + add(CatalogItem("Lait infantile", "Bébé", "🍼")) + add(CatalogItem("Compote bébé", "Bébé", "🍎")) + add(CatalogItem("Lingettes bébé", "Bébé", "🧻")) + + // Animaux + add(CatalogItem("Croquettes chien", "Animaux", "🐶")) + add(CatalogItem("Croquettes chat", "Animaux", "🐱")) + add(CatalogItem("Pâtée chat", "Animaux", "🐈")) + } + + /** Items pour une catégorie donnée (ordre catalogue). */ + fun itemsForCategory(category: String): List = + items.filter { it.category == category } + + /** + * Recherche dans le catalogue. Retourne au maximum [limit] résultats triés par + * pertinence : préfixe d'abord, puis sous-chaîne. + */ + fun search(query: String, limit: Int = 8): List { + val q = query.trim().lowercase() + if (q.isEmpty()) return emptyList() + val prefix = items.filter { it.name.lowercase().startsWith(q) } + val contains = items.filter { + !it.name.lowercase().startsWith(q) && it.matches(q) + } + return (prefix + contains).take(limit) + } + + /** + * Suggestions populaires (utilisées dans la barre de saisie quand vide, + * équivalent du panneau "Vous avez sûrement besoin"). + */ + val popularSuggestions: List = listOf( + items.first { it.name == "Lait" }, + items.first { it.name == "Pain" }, + items.first { it.name == "Œufs" }, + items.first { it.name == "Beurre" }, + items.first { it.name == "Pomme" }, + items.first { it.name == "Pâtes" }, + items.first { it.name == "Tomate" }, + items.first { it.name == "Yaourt" }, + items.first { it.name == "Papier toilette" } + ) + + /** + * Retourne un emoji représentatif pour un nom d'article libre. Utilise d'abord + * une correspondance exacte puis un repli par catégorie. + */ + fun emojiFor(name: String, category: String?): String { + val direct = items.firstOrNull { it.name.equals(name, ignoreCase = true) } + if (direct != null) return direct.emoji + return when (category) { + "Fruits & Légumes" -> "🥗" + "Boulangerie" -> "🥖" + "Produits laitiers" -> "🥛" + "Boucherie" -> "🥩" + "Épicerie" -> "🛒" + "Boissons" -> "🥤" + "Surgelés" -> "🧊" + "Hygiène" -> "🧴" + "Entretien" -> "🧹" + "Bébé" -> "👶" + "Animaux" -> "🐾" + else -> "📦" + } + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt index 3d2ebec..47cb347 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt @@ -1,16 +1,9 @@ package com.safebite.app.presentation.screen.lists -import android.content.Intent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -19,95 +12,98 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding 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.LazyRow -import androidx.compose.foundation.lazy.items -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.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.icons.automirrored.filled.MergeType +import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar -import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat.startActivity import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.AsyncImage -import com.safebite.app.R -import com.safebite.app.data.local.database.entity.ShoppingListItemEntity -import com.safebite.app.presentation.common.components.EmptyState -import com.safebite.app.presentation.common.components.PrimaryButton +import com.safebite.app.domain.engine.CatalogProvider import com.safebite.app.presentation.theme.LocalDimens import com.safebite.app.presentation.theme.LocalStatusColors /** - * Écran détail d'une liste de courses (Phase 2 — spec UX FLOW 5). - * - * Fonctionnalités : - * - Affichage des produits avec verdicts (✅/⚠️/) - * - Chips filtres par rayon - * - Swipe right : cocher/décocher - * - Swipe left : supprimer (avec undo) - * - Champ de recherche "J'ai besoin ..." en bas - * - Section "Recently Used" avec produits récemment scannés - * - Catégories de produits cliquables + * Écran détail d'une liste — refonte type Bring!. + * + * Hiérarchie verticale : + * 1. Tuiles **Articles actifs** (rouge) — tap = marque comme acheté. + * 2. Section repliable **Recently Used** (vert) — tap = restaure. + * 3. Sections par catégorie du catalogue — tap = ajoute à la liste. + * 4. Barre de saisie persistante en bas + bouton "+". + * + * Gestes : + * - Tap court : action principale (achat / restauration / ajout). + * - Appui long sur un article (actif ou recently used) → feuille de détail. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -119,75 +115,88 @@ fun ListDetailScreen( onOpenProduct: (String) -> Unit, viewModel: ListDetailViewModel = hiltViewModel() ) { - val state by viewModel.state.collectAsStateWithLifecycle() - val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() - val showSearch by viewModel.showSearch.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current - var selectedCategory by remember { mutableStateOf("Tous") } - var showMenu by remember { mutableStateOf(false) } - var showAddManualDialog by remember { mutableStateOf(false) } - LaunchedEffect(listId, listName) { viewModel.initList(listId, listName) } + val state by viewModel.state.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val suggestions by viewModel.suggestions.collectAsStateWithLifecycle() + val selectedItemId by viewModel.selectedItemId.collectAsStateWithLifecycle() + val otherLists by viewModel.otherLists.collectAsStateWithLifecycle() + + var menuExpanded by remember { mutableStateOf(false) } + var recentlyExpanded by remember { mutableStateOf(true) } + val expandedCategories = remember { mutableStateMapOf() } + Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( - title = { Text(listName) }, + title = { + Text( + text = listName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, navigationIcon = { IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - "Retour" - ) + Icon(Icons.Filled.ArrowBack, contentDescription = "Retour") } }, actions = { - IconButton(onClick = { viewModel.toggleSearch() }) { - Icon( - if (showSearch) Icons.Filled.Close else Icons.Filled.Search, - if (showSearch) "Fermer la recherche" else "Rechercher" - ) + IconButton(onClick = onOpenScanner) { + Icon(Icons.Filled.Camera, contentDescription = "Scanner") } - IconButton(onClick = { showMenu = true }) { - Icon(Icons.Filled.MoreVert, "Menu") - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { - DropdownMenuItem( - text = { Text("Partager la liste") }, - leadingIcon = { Icon(Icons.Filled.Share, null) }, - onClick = { - showMenu = false - if (state is ListDetailViewModel.UiState.Success) { - val s = state as ListDetailViewModel.UiState.Success - val shareText = viewModel.shareList(s.listName, s.items) - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, shareText) - } - context.startActivity(Intent.createChooser(intent, "Partager via")) + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = "Options") + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + DropdownMenuItem( + text = { Text("Tout décocher") }, + leadingIcon = { Icon(Icons.Filled.Check, contentDescription = null) }, + onClick = { + menuExpanded = false + viewModel.uncheckAllItems() } - } - ) - DropdownMenuItem( - text = { Text("Fusionner avec une autre liste") }, - leadingIcon = { Icon(Icons.AutoMirrored.Filled.MergeType, null) }, - onClick = { - showMenu = false - // TODO: Implémenter la fusion - } - ) + ) + DropdownMenuItem( + text = { Text("Vider Recently Used") }, + leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }, + onClick = { + menuExpanded = false + viewModel.clearRecentlyUsed() + } + ) + DropdownMenuItem( + text = { Text("Partager") }, + leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) }, + onClick = { menuExpanded = false } + ) + } } } ) }, - snackbarHost = { SnackbarHost(snackbarHostState) } + bottomBar = { + BottomSearchBar( + query = searchQuery, + onQueryChange = viewModel::updateSearchQuery, + onClear = viewModel::clearSearch, + onAddCustom = { + if (searchQuery.isNotBlank()) { + viewModel.addCustomItem(searchQuery) + } + } + ) + } ) { padding -> Box( modifier = Modifier @@ -198,574 +207,840 @@ fun ListDetailScreen( is ListDetailViewModel.UiState.Loading -> { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } - is ListDetailViewModel.UiState.Empty -> { - ListEmptyContent( - listName = s.listName, - recentlyUsed = s.recentlyUsed, - selectedCategory = selectedCategory, - onCategorySelected = { selectedCategory = it }, - onProductClick = { product -> - viewModel.addProductFromHistory(product) - }, - onOpenScanner = onOpenScanner, - onAddManual = { showAddManualDialog = true } + is ListDetailViewModel.UiState.Error -> { + Text( + text = s.message, + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.error ) } - is ListDetailViewModel.UiState.Success -> { - ListContent( - items = s.items, - categories = s.categories, - recentlyUsed = s.recentlyUsed, - selectedCategory = selectedCategory, - searchQuery = searchQuery, - showSearch = showSearch, - onCategorySelected = { selectedCategory = it }, - onToggleCheck = { item -> - viewModel.toggleItemChecked(item.id, !item.isChecked) + is ListDetailViewModel.UiState.Ready -> { + ListDetailContent( + ready = s, + catalog = viewModel.catalog, + recentlyExpanded = recentlyExpanded, + onToggleRecently = { recentlyExpanded = !recentlyExpanded }, + expandedCategories = expandedCategories, + onToggleCategory = { cat -> + expandedCategories[cat] = !(expandedCategories[cat] ?: false) }, - onDelete = { item -> - viewModel.deleteItem( - ShoppingListItemEntity( - id = item.id, - listId = s.listId, - productName = item.productName - ) + onTapActive = viewModel::markAsBought, + onLongPressActive = viewModel::openItemDetails, + onTapRecent = viewModel::restoreItem, + onLongPressRecent = viewModel::openItemDetails, + onTapCatalog = viewModel::addCatalogItem + ) + } + } + + // Overlay des suggestions (au-dessus du contenu, sous la barre de saisie). + AnimatedVisibility( + visible = suggestions.isNotEmpty(), + modifier = Modifier.align(Alignment.BottomCenter) + ) { + SuggestionPanel( + suggestions = suggestions, + onPick = viewModel::applySuggestion + ) + } + } + } + + // Feuille de détail (long-press) + val ready = state as? ListDetailViewModel.UiState.Ready + val selected = ready?.let { r -> + r.activeItems.firstOrNull { it.id == selectedItemId } + ?: r.recentlyUsed.firstOrNull { it.id == selectedItemId } + } + if (selected != null) { + ItemDetailSheet( + item = selected, + otherLists = otherLists.filter { it.id != ready.listId }, + categories = viewModel.catalog.categories, + onDismiss = viewModel::closeItemDetails, + onUpdateNote = { note -> viewModel.updateItemNote(selected.id, note) }, + onUpdateCategory = { cat -> viewModel.updateItemCategory(selected.id, cat) }, + onMoveTo = { targetListId -> viewModel.moveItemToList(selected.id, targetListId) }, + onDelete = { viewModel.deleteItem(selected.id) }, + onOpenProduct = selected.barcode?.let { bc -> { onOpenProduct(bc) } } + ) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Contenu principal scrollable +// ───────────────────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ListDetailContent( + ready: ListDetailViewModel.UiState.Ready, + catalog: CatalogProvider, + recentlyExpanded: Boolean, + onToggleRecently: () -> Unit, + expandedCategories: Map, + onToggleCategory: (String) -> Unit, + onTapActive: (Long) -> Unit, + onLongPressActive: (Long) -> Unit, + onTapRecent: (Long) -> Unit, + onLongPressRecent: (Long) -> Unit, + onTapCatalog: (CatalogProvider.CatalogItem) -> Unit +) { + val dimens = LocalDimens.current + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = dimens.spacingMd, + end = dimens.spacingMd, + top = dimens.spacingMd, + bottom = dimens.spacingXl + 80.dp // espace pour la barre de saisie + ), + verticalArrangement = Arrangement.spacedBy(dimens.spacingSm) + ) { + // ── Articles actifs ───────────────────────────────────────────────── + if (ready.activeItems.isEmpty()) { + item { + EmptyActiveCard() + } + } else { + item { + TileGrid( + items = ready.activeItems.map { + TileData( + id = it.id, + label = it.productName, + note = it.note, + emoji = it.emoji, + imageUrl = it.imageUrl, + tone = TileTone.Active, + badgeWarning = !it.allergenWarning.isNullOrBlank() + ) + }, + onTap = onTapActive, + onLongPress = onLongPressActive + ) + } + } + + // ── Recently Used ─────────────────────────────────────────────────── + item { + CollapsibleHeader( + title = "Recently Used", + count = ready.recentlyUsed.size, + expanded = recentlyExpanded, + onToggle = onToggleRecently, + leadingIcon = Icons.Filled.History + ) + } + if (recentlyExpanded && ready.recentlyUsed.isNotEmpty()) { + item { + TileGrid( + items = ready.recentlyUsed.map { + TileData( + id = it.id, + label = it.productName, + note = it.note, + emoji = it.emoji, + imageUrl = it.imageUrl, + tone = TileTone.Recent, + badgeWarning = false + ) + }, + onTap = onTapRecent, + onLongPress = onLongPressRecent + ) + } + } + + // ── Catalogue par catégorie ───────────────────────────────────────── + item { + HorizontalDivider( + modifier = Modifier.padding(vertical = dimens.spacingSm), + color = MaterialTheme.colorScheme.outlineVariant + ) + Text( + text = "Catalogue", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold + ) + } + + catalog.categories.forEach { category -> + val expanded = expandedCategories[category] ?: false + item(key = "header-$category") { + CollapsibleHeader( + title = category, + count = catalog.itemsForCategory(category).size, + expanded = expanded, + onToggle = { onToggleCategory(category) } + ) + } + if (expanded) { + item(key = "grid-$category") { + val items = catalog.itemsForCategory(category) + TileGrid( + items = items.map { + TileData( + id = it.name.hashCode().toLong(), + label = it.name, + note = null, + emoji = it.emoji, + imageUrl = null, + tone = TileTone.Catalog, + badgeWarning = false ) }, - onProductClick = { item -> - item.barcode?.let { onOpenProduct(it) } + onTap = { idHash -> + val target = items.firstOrNull { it.name.hashCode().toLong() == idHash } + target?.let(onTapCatalog) }, - onProductFromHistoryClick = { product -> - viewModel.addProductFromHistory(product) - }, - onOpenScanner = onOpenScanner, - onAddManual = { showAddManualDialog = true }, - onUncheckAll = { viewModel.uncheckAllItems() } - ) - } - is ListDetailViewModel.UiState.Error -> { - EmptyState( - title = "Erreur", - message = s.message, - emoji = "❌" + onLongPress = { /* pas d'action sur le catalogue */ } ) } } } } +} - if (showAddManualDialog) { - AddProductDialog( - onDismiss = { showAddManualDialog = false }, - onAdd = { name -> - viewModel.addItemToList( - ShoppingListItemEntity( - listId = listId, - productName = name - ) - ) - showAddManualDialog = false +// ───────────────────────────────────────────────────────────────────────────── +// Tuiles +// ───────────────────────────────────────────────────────────────────────────── + +private enum class TileTone { Active, Recent, Catalog } + +private data class TileData( + val id: Long, + val label: String, + val note: String?, + val emoji: String, + val imageUrl: String?, + val tone: TileTone, + val badgeWarning: Boolean +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TileGrid( + items: List, + onTap: (Long) -> Unit, + onLongPress: (Long) -> Unit +) { + if (items.isEmpty()) return + val dimens = LocalDimens.current + val columns = 3 + val rows = items.chunked(columns) + Column(verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)) { + rows.forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm) + ) { + row.forEach { tile -> + Box(modifier = Modifier.weight(1f)) { + Tile( + data = tile, + onTap = { onTap(tile.id) }, + onLongPress = { onLongPress(tile.id) } + ) + } + } + // Remplissage des cellules manquantes pour conserver une largeur constante + repeat(columns - row.size) { + Spacer(modifier = Modifier.weight(1f)) + } } - ) + } } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun ListEmptyContent( - listName: String, - recentlyUsed: List, - selectedCategory: String, - onCategorySelected: (String) -> Unit, - onProductClick: (ListDetailViewModel.RecentlyUsedProduct) -> Unit, - onOpenScanner: () -> Unit, - onAddManual: () -> Unit +private fun Tile( + data: TileData, + onTap: () -> Unit, + onLongPress: () -> Unit ) { - Column(modifier = Modifier.fillMaxSize()) { - // Message vide - EmptyState( - title = "Liste vide", - message = "Ajoutez des produits à votre liste", - emoji = "🛒", - action = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - PrimaryButton( - text = "Scanner un produit", - onClick = onOpenScanner, - modifier = Modifier.weight(1f) - ) - PrimaryButton( - text = "Ajouter manuellement", - onClick = onAddManual, - modifier = Modifier.weight(1f) + val statusColors = LocalStatusColors.current + val (container, content) = when (data.tone) { + TileTone.Active -> statusColors.danger.copy(alpha = 0.85f) to Color.White + TileTone.Recent -> statusColors.safe.copy(alpha = 0.45f) to Color.White + TileTone.Catalog -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant + } + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .combinedClickable( + onClick = onTap, + onLongClick = onLongPress + ), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = container), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxSize() + ) { + Text( + text = data.emoji, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(top = 4.dp) + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = data.label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = content, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + if (!data.note.isNullOrBlank()) { + Text( + text = data.note, + style = MaterialTheme.typography.labelSmall, + color = content.copy(alpha = 0.85f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } - ) + if (data.badgeWarning) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = "Allergène", + tint = statusColors.warning, + modifier = Modifier + .align(Alignment.TopEnd) + .size(16.dp) + ) + } + } + } +} - // Section Recently Used - if (recentlyUsed.isNotEmpty()) { - RecentlyUsedSection( - products = recentlyUsed, - selectedCategory = selectedCategory, - onCategorySelected = onCategorySelected, - onProductClick = onProductClick +// ───────────────────────────────────────────────────────────────────────────── +// En-tête repliable +// ───────────────────────────────────────────────────────────────────────────── + +@Composable +private fun CollapsibleHeader( + title: String, + count: Int, + expanded: Boolean, + onToggle: () -> Unit, + leadingIcon: androidx.compose.ui.graphics.vector.ImageVector? = null +) { + val rotation by animateFloatAsState(targetValue = if (expanded) 90f else 0f, label = "chevron") + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onToggle) + .padding(vertical = 12.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.rotate(rotation), + tint = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(4.dp)) + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + if (count > 0) { + Text( + text = "$count", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } +// ───────────────────────────────────────────────────────────────────────────── +// État vide pour la liste active +// ───────────────────────────────────────────────────────────────────────────── + @Composable -private fun ListContent( - items: List, - categories: List, - recentlyUsed: List, - selectedCategory: String, - searchQuery: String, - showSearch: Boolean, - onCategorySelected: (String) -> Unit, - onToggleCheck: (ListDetailViewModel.ShoppingListItemUi) -> Unit, - onDelete: (ListDetailViewModel.ShoppingListItemUi) -> Unit, - onProductClick: (ListDetailViewModel.ShoppingListItemUi) -> Unit, - onProductFromHistoryClick: (ListDetailViewModel.RecentlyUsedProduct) -> Unit, - onOpenScanner: () -> Unit, - onAddManual: () -> Unit, - onUncheckAll: () -> Unit -) { - val dimens = LocalDimens.current - - Column(modifier = Modifier.fillMaxSize()) { - // Chips filtres par rayon - if (categories.size > 1) { - LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(categories) { category -> - FilterChip( - selected = selectedCategory == category, - onClick = { onCategorySelected(category) }, - label = { Text(category) } - ) - } - } - } - - // Liste des produits - val filteredItems = if (selectedCategory == "Tous") { - items - } else { - items.filter { it.category == selectedCategory } - } - - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(16.dp), +private fun EmptyActiveCard() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items( - filteredItems, - key = { it.id } - ) { item -> - SwipeableListItem( - item = item, - onToggleCheck = { onToggleCheck(item) }, - onDelete = { onDelete(item) }, - onProductClick = { onProductClick(item) } - ) - } - - // Section Recently Used en bas de la liste - if (recentlyUsed.isNotEmpty()) { - item { - Spacer(Modifier.height(16.dp)) - RecentlyUsedSection( - products = recentlyUsed, - selectedCategory = selectedCategory, - onCategorySelected = onCategorySelected, - onProductClick = onProductFromHistoryClick - ) - } - } - } - - // Bottom bar - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - TextButton( - onClick = onUncheckAll, - modifier = Modifier.weight(1f) - ) { - Text("Tout décocher") - } - PrimaryButton( - text = "Ajouter", - onClick = onAddManual, - modifier = Modifier.weight(1f) - ) - } - } -} - -@Composable -private fun RecentlyUsedSection( - products: List, - selectedCategory: String, - onCategorySelected: (String) -> Unit, - onProductClick: (ListDetailViewModel.RecentlyUsedProduct) -> Unit -) { - var expanded by remember { mutableStateOf(false) } - val dimens = LocalDimens.current - - Column(modifier = Modifier.fillMaxWidth()) { - // Header Recently Used - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = !expanded } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { + Text(text = "🛒", style = MaterialTheme.typography.displaySmall) Text( - text = "Recently Used", + text = "Votre liste est vide", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) - Icon( - imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, - contentDescription = if (expanded) "Réduire" else "Développer" + Text( + text = "Tapez un article ci-dessous ou parcourez le catalogue", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center ) } + } +} - // Contenu expandable - AnimatedVisibility( - visible = expanded, - enter = fadeIn() + slideInVertically(), - exit = fadeOut() + slideOutVertically() +// ───────────────────────────────────────────────────────────────────────────── +// Barre de saisie persistante + bouton "+" +// ───────────────────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BottomSearchBar( + query: String, + onQueryChange: (String) -> Unit, + onClear: () -> Unit, + onAddCustom: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Column(modifier = Modifier.fillMaxWidth()) { - // Grille de produits - LazyVerticalGrid( - columns = GridCells.Fixed(3), - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 300.dp) - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(bottom = 16.dp) - ) { - items(products) { product -> - RecentlyUsedProductCard( - product = product, - onClick = { onProductClick(product) } + TextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("J'ai besoin…") }, + singleLine = true, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(28.dp), + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = onClear) { + Icon(Icons.Filled.Clear, contentDescription = "Effacer") + } + } + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ) + ) + FloatingActionButton( + onClick = onAddCustom, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.Filled.Add, contentDescription = "Ajouter") + } + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Panneau de suggestions +// ───────────────────────────────────────────────────────────────────────────── + +@Composable +private fun SuggestionPanel( + suggestions: List, + onPick: (ListDetailViewModel.Suggestion) -> Unit +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.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()) + ) { + suggestions.forEach { suggestion -> + SuggestionRow( + suggestion = suggestion, + onPick = { onPick(suggestion) } + ) + } + Spacer(modifier = Modifier.height(72.dp)) // espace pour la barre de saisie + } + } +} + +@Composable +private fun SuggestionRow( + suggestion: ListDetailViewModel.Suggestion, + 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.Create -> "Créer" to MaterialTheme.colorScheme.primary + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onPick) + .padding(vertical = 10.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = suggestion.emoji, style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = suggestion.label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = badge, + style = MaterialTheme.typography.labelSmall, + color = badgeColor + ) + } + if (suggestion is ListDetailViewModel.Suggestion.Create) { + Icon( + imageVector = Icons.Filled.AutoAwesome, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } else { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Feuille de détail (long-press) +// ───────────────────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ItemDetailSheet( + item: ListDetailViewModel.ShoppingListItemUi, + otherLists: List, + categories: List, + onDismiss: () -> Unit, + onUpdateNote: (String) -> Unit, + onUpdateCategory: (String) -> Unit, + onMoveTo: (Long) -> Unit, + onDelete: () -> Unit, + onOpenProduct: (() -> Unit)? +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var note by remember(item.id) { mutableStateOf(item.note.orEmpty()) } + var showCategoryPicker by remember { mutableStateOf(false) } + var showMovePicker by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + + ModalBottomSheet( + onDismissRequest = { + // Persiste la note avant fermeture si elle a changé. + if (note != (item.note.orEmpty())) onUpdateNote(note) + onDismiss() + }, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // En-tête + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = item.emoji, style = MaterialTheme.typography.displaySmall) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.productName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + if (!item.brand.isNullOrBlank()) { + Text( + text = item.brand, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } + TextButton(onClick = { + if (note != (item.note.orEmpty())) onUpdateNote(note) + onDismiss() + }) { + Text("Terminé") + } + } + + // Quantité / description + OutlinedTextField( + value = note, + onValueChange = { note = it }, + label = { Text("Quantité, description…") }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + maxLines = 3 + ) + + // Catégorie + ActionRow( + title = "Section", + value = item.category ?: "Autre", + onClick = { showCategoryPicker = true } + ) + + // Déplacer + if (otherLists.isNotEmpty()) { + ActionRow( + title = "Déplacer vers une autre liste", + value = null, + onClick = { showMovePicker = true }, + leadingIcon = Icons.Filled.SwapHoriz + ) + } + + // Ouvrir fiche produit + if (onOpenProduct != null) { + ActionRow( + title = "Voir la fiche produit", + value = null, + onClick = { + focusManager.clearFocus() + onOpenProduct() + }, + leadingIcon = Icons.Filled.Camera + ) + } + + // Avertissement allergène éventuel + if (!item.allergenWarning.isNullOrBlank()) { + Card( + colors = CardDefaults.cardColors( + containerColor = LocalStatusColors.current.warningContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = LocalStatusColors.current.warning + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = item.allergenWarning, + style = MaterialTheme.typography.bodyMedium, + color = LocalStatusColors.current.onWarningContainer + ) + } + } + } + + // Suppression définitive + TextButton( + onClick = onDelete, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Supprimer l'article", + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + // Sélecteur de catégorie + if (showCategoryPicker) { + ModalBottomSheet( + onDismissRequest = { showCategoryPicker = false } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .navigationBarsPadding() + ) { + Text( + text = "Choisir une section", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + categories.forEach { cat -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + onUpdateCategory(cat) + showCategoryPicker = false + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = cat, modifier = Modifier.weight(1f)) + if (cat == item.category) { + Icon( + imageVector = Icons.Filled.Done, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } + + // Sélecteur de liste cible + if (showMovePicker) { + ModalBottomSheet( + onDismissRequest = { showMovePicker = false } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .navigationBarsPadding() + ) { + Text( + text = "Déplacer vers…", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + otherLists.forEach { list -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + onMoveTo(list.id) + showMovePicker = false + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.SwapHoriz, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = list.name) + } + } } } } } @Composable -private fun RecentlyUsedProductCard( - product: ListDetailViewModel.RecentlyUsedProduct, - onClick: () -> Unit +private fun ActionRow( + title: String, + value: String?, + onClick: () -> Unit, + leadingIcon: androidx.compose.ui.graphics.vector.ImageVector? = null ) { - val dimens = LocalDimens.current - val statusColors = LocalStatusColors.current - - Card( + Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(dimens.spacingSm), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Image ou icône - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center - ) { - if (!product.imageUrl.isNullOrBlank()) { - AsyncImage( - model = product.imageUrl, - contentDescription = product.productName, - modifier = Modifier.fillMaxSize() - ) - } else { - Text( - text = getCategoryEmoji(product.category), - style = MaterialTheme.typography.headlineSmall - ) - } - } - - Spacer(Modifier.height(4.dp)) - - // Nom du produit + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Column(modifier = Modifier.weight(1f)) { Text( - text = product.productName, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth() + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium ) - - // Badge statut - if (!product.safetyStatus.isNullOrBlank()) { - val (icon, color) = when (product.safetyStatus) { - "SAFE" -> "✅" to statusColors.safe - "WARNING" -> "⚠️" to statusColors.warning - "DANGER" -> "❌" to statusColors.danger - else -> "" to Color.Gray - } + if (!value.isNullOrBlank()) { Text( - text = icon, - style = MaterialTheme.typography.bodySmall + text = value, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - } -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) -@Composable -private fun SwipeableListItem( - item: ListDetailViewModel.ShoppingListItemUi, - onToggleCheck: () -> Unit, - onDelete: () -> Unit, - onProductClick: () -> Unit -) { - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { value -> - when (value) { - SwipeToDismissBoxValue.EndToStart -> { - onDelete() - true - } - SwipeToDismissBoxValue.StartToEnd -> { - onToggleCheck() - true - } - else -> false - } - } - ) - - SwipeToDismissBox( - state = dismissState, - backgroundContent = { - val color = when (dismissState.targetValue) { - SwipeToDismissBoxValue.StartToEnd -> LocalStatusColors.current.safe - SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.error - else -> Color.Transparent - } - Row( - modifier = Modifier - .fillMaxSize() - .background(color) - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White) - Icon(Icons.Filled.Delete, contentDescription = null, tint = Color.White) - } - }, - content = { - ShoppingListItemRow( - item = item, - onToggleCheck = onToggleCheck, - onProductClick = onProductClick - ) - } - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun ShoppingListItemRow( - item: ListDetailViewModel.ShoppingListItemUi, - onToggleCheck: () -> Unit, - onProductClick: () -> Unit -) { - val dimens = LocalDimens.current - val backgroundColor = if (item.isChecked) { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.surface - } - - Card( - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = onProductClick, - onLongClick = onToggleCheck - ), - colors = CardDefaults.cardColors(containerColor = backgroundColor) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(dimens.spacingMd), - verticalAlignment = Alignment.CenterVertically - ) { - // Checkbox - Box( - modifier = Modifier - .size(24.dp) - .background( - color = if (item.isChecked) MaterialTheme.colorScheme.primary else Color.Transparent, - shape = MaterialTheme.shapes.small - ) - .combinedClickable(onClick = onToggleCheck), - contentAlignment = Alignment.Center - ) { - if (item.isChecked) { - Icon( - Icons.Filled.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(16.dp) - ) - } - } - - Spacer(Modifier.width(dimens.spacingSm)) - - // Image du produit - if (!item.imageUrl.isNullOrBlank()) { - AsyncImage( - model = item.imageUrl, - contentDescription = item.productName, - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(4.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - ) - Spacer(Modifier.width(dimens.spacingSm)) - } - - // Product info - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.productName, - style = MaterialTheme.typography.bodyLarge, - textDecoration = if (item.isChecked) TextDecoration.LineThrough else null, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (!item.brand.isNullOrBlank()) { - Text( - text = item.brand, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - // Safety status indicator - val statusColors = LocalStatusColors.current - if (!item.safetyStatus.isNullOrBlank()) { - val (icon, color) = when (item.safetyStatus) { - "SAFE" -> "✅" to statusColors.safe - "WARNING" -> "⚠️" to statusColors.warning - "DANGER" -> "❌" to statusColors.danger - else -> "" to Color.Gray - } - Text( - text = icon, - style = MaterialTheme.typography.bodyLarge - ) - } - - // Allergen warning - if (!item.allergenWarning.isNullOrBlank()) { - Icon( - Icons.Filled.Warning, - contentDescription = null, - tint = LocalStatusColors.current.warning, - modifier = Modifier.size(20.dp) - ) - } - } - } -} - -@Composable -private fun AddProductDialog( - onDismiss: () -> Unit, - onAdd: (String) -> Unit -) { - var productName by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Ajouter un produit") }, - text = { - OutlinedTextField( - value = productName, - onValueChange = { productName = it }, - label = { Text("Nom du produit") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - }, - confirmButton = { - TextButton( - onClick = { if (productName.isNotBlank()) onAdd(productName) }, - enabled = productName.isNotBlank() - ) { - Text("Ajouter") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Annuler") - } - } - ) -} - -/** - * Retourne un emoji pour une catégorie donnée. - */ -private fun getCategoryEmoji(category: String?): String { - return when (category) { - "Frais" -> "🥬" - "Fruits & Légumes" -> "🍎" - "Boulangerie" -> "🥖" - "Boucherie" -> "🥩" - "Produits laitiers" -> "🥛" - "Épicerie" -> "🛒" - "Boissons" -> "🥤" - "Surgelés" -> "🧊" - "Hygiène" -> "🧴" - "Bébé" -> "👶" - "Animaux" -> "🐾" - "Entretien" -> "🧹" - else -> "📦" + Icon( + imageVector = Icons.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt index 74d754c..cd9f4e7 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt @@ -2,10 +2,11 @@ package com.safebite.app.presentation.screen.lists import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.safebite.app.data.local.database.entity.ShoppingListEntity import com.safebite.app.data.local.database.entity.ShoppingListItemEntity +import com.safebite.app.domain.engine.CatalogProvider import com.safebite.app.domain.engine.CategoryEngine -import com.safebite.app.domain.model.ScanHistoryItem -import com.safebite.app.domain.usecase.GetScanHistoryUseCase +import com.safebite.app.domain.usecase.GetShoppingListsUseCase import com.safebite.app.domain.usecase.ManageShoppingListUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -19,31 +20,36 @@ import kotlinx.coroutines.launch import javax.inject.Inject /** - * ViewModel pour l'écran détail d'une liste (Phase 2). - * - * Gère: - * - L'affichage des produits dans la liste - * - L'ajout de produits (manuel ou depuis l'historique) - * - La recherche de produits - * - Les catégories de produits + * ViewModel pour l'écran détail d'une liste — refonte type Bring!. + * + * Modèle de données : + * - **Articles actifs** : items de la liste avec `isChecked = false`. Affichés en + * tuiles (rouges) en haut. Un tap → "marqué comme acheté" (déplace vers Recently + * Used). Appui long → feuille de détail. + * - **Recently Used** : items de la liste avec `isChecked = true`, triés par date. + * Affichés en tuiles (vertes) sous une section repliable. Tap → réactive (retour + * dans la liste active). Appui long → suppression définitive. + * - **Catalogue** : sections par rayon (Fruits & Légumes, etc.) avec articles + * prédéfinis. Tap → ajoute à la liste active. + * - **Recherche** : la barre "J'ai besoin…" filtre catalogue + recently used. Si + * aucune correspondance, on peut créer un *own item*. */ @HiltViewModel class ListDetailViewModel @Inject constructor( private val manageListUseCase: ManageShoppingListUseCase, - private val getScanHistoryUseCase: GetScanHistoryUseCase, - private val categoryEngine: CategoryEngine + private val getListsUseCase: GetShoppingListsUseCase, + private val categoryEngine: CategoryEngine, + val catalog: CatalogProvider ) : ViewModel() { sealed class UiState { - object Loading : UiState() - data class Success( + data object Loading : UiState() + data class Ready( val listId: Long, val listName: String, - val items: List, - val categories: List, - val recentlyUsed: List + val activeItems: List, + val recentlyUsed: List ) : UiState() - data class Empty(val listId: Long, val listName: String, val recentlyUsed: List) : UiState() data class Error(val message: String) : UiState() } @@ -56,64 +62,74 @@ class ListDetailViewModel @Inject constructor( val isChecked: Boolean, val category: String?, val safetyStatus: String?, - val allergenWarning: String? + val allergenWarning: String?, + val note: String?, + val emoji: String ) - data class RecentlyUsedProduct( - val barcode: String, - val productName: String, - val brand: String?, - val imageUrl: String?, - val safetyStatus: String?, - val category: 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 + * proposition de création d'un nouvel article. + */ + sealed class Suggestion { + abstract val label: String + abstract val emoji: String + + data class Catalog(val item: CatalogProvider.CatalogItem) : Suggestion() { + override val label: String = item.name + override val emoji: String = item.emoji + } + data class Recent(val item: ShoppingListItemUi) : Suggestion() { + override val label: String = item.productName + override val emoji: String = item.emoji + } + data class Active(val item: ShoppingListItemUi) : Suggestion() { + override val label: String = item.productName + override val emoji: String = item.emoji + } + data class Create(val rawText: String) : Suggestion() { + override val label: String = rawText + override val emoji: String = "✨" + } + } private val _listIdFlow = MutableStateFlow(0L) - private var _listName: String = "" - + private val _listName = MutableStateFlow("") + private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow = _searchQuery - - private val _showSearch = MutableStateFlow(false) - val showSearch: StateFlow = _showSearch + + /** Article actuellement ouvert dans la feuille de détail (long-press). */ + private val _selectedItemId = MutableStateFlow(null) + val selectedItemId: StateFlow = _selectedItemId + + /** Listes disponibles pour l'action "Déplacer l'article". */ + val otherLists: StateFlow> = getListsUseCase + .observeActive() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) fun initList(listId: Long, listName: String) { _listIdFlow.value = listId - _listName = listName + _listName.value = listName } @OptIn(ExperimentalCoroutinesApi::class) val state: StateFlow = _listIdFlow.flatMapLatest { listId -> combine( manageListUseCase.observeItems(listId), - getScanHistoryUseCase.observe(), - _searchQuery - ) { items, history, query -> - // Convertir l'historique en produits récemment utilisés - val recentlyUsed = history.take(20).map { it.toRecentlyUsed() } - - // Filtrer les items selon la recherche - val filteredItems = if (query.isBlank()) { - items - } else { - items.filter { - it.productName.contains(query, ignoreCase = true) || - it.brand?.contains(query, ignoreCase = true) == true - } - } - - if (items.isEmpty()) { - UiState.Empty(listId, _listName, recentlyUsed) - } else { - val categories = items.mapNotNull { it.category }.distinct().sorted() - UiState.Success( - listId = listId, - listName = _listName, - items = filteredItems.map { it.toUi() }, - categories = listOf("Tous") + categories, - recentlyUsed = recentlyUsed - ) - } + _listName + ) { items, listName -> + val ui = items.map { it.toUi() } + UiState.Ready( + listId = listId, + listName = listName, + activeItems = ui.filterNot { it.isChecked } + .sortedBy { it.productName.lowercase() }, + recentlyUsed = ui.filter { it.isChecked } + // Most-recently bought first (proxy: addedAt ordering preserved by DAO) + .take(MAX_RECENTLY_USED) + ) } }.stateIn( scope = viewModelScope, @@ -121,114 +137,250 @@ class ListDetailViewModel @Inject constructor( initialValue = UiState.Loading ) - fun toggleItemChecked(id: Long, checked: Boolean) { + /** Liste filtrée des suggestions affichées au-dessus de la barre de saisie. */ + val suggestions: StateFlow> = combine( + _searchQuery, + state + ) { rawQuery, uiState -> + val query = rawQuery.trim() + if (query.isEmpty()) return@combine emptyList() + + val ready = uiState as? UiState.Ready ?: return@combine emptyList() + val results = mutableListOf() + + // 1) Articles déjà sur la liste active (priorité haute pour rappel) + ready.activeItems + .filter { it.productName.contains(query, ignoreCase = true) } + .take(2) + .forEach { results.add(Suggestion.Active(it)) } + + // 2) Recently used → restauration rapide + ready.recentlyUsed + .filter { it.productName.contains(query, ignoreCase = true) } + .take(3) + .forEach { results.add(Suggestion.Recent(it)) } + + // 3) Catalogue + catalog.search(query, limit = 6) + .filter { item -> + results.none { it.label.equals(item.name, ignoreCase = true) } + } + .forEach { results.add(Suggestion.Catalog(it)) } + + // 4) Création d'un own item si aucune correspondance exacte + val exact = results.any { it.label.equals(query, ignoreCase = true) } + if (!exact) { + results.add(0, Suggestion.Create(query)) + } + results + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + // ── Actions ───────────────────────────────────────────────────────────── + + /** Met à jour le texte saisi dans la barre "J'ai besoin…". */ + fun updateSearchQuery(query: String) { + _searchQuery.value = query + } + + fun clearSearch() { + _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.Recent -> restoreItem(suggestion.item.id) + is Suggestion.Active -> { /* déjà sur la liste, ne fait rien */ } + is Suggestion.Create -> addCustomItem(suggestion.rawText) + } + clearSearch() + } + + /** Ajoute un article du catalogue à la liste active. */ + fun addCatalogItem(catalogItem: CatalogProvider.CatalogItem) { viewModelScope.launch { - manageListUseCase.setItemChecked(id, checked) + val listId = _listIdFlow.value + // Si l'article existe déjà (en actif ou recently used), on le réactive. + val existing = manageListUseCase.getItems(listId) + .firstOrNull { it.productName.equals(catalogItem.name, ignoreCase = true) } + if (existing != null) { + if (existing.isChecked) { + manageListUseCase.setItemChecked(existing.id, false) + } + return@launch + } + manageListUseCase.addItemToList( + listId, + ShoppingListItemEntity( + listId = listId, + productName = catalogItem.name, + category = catalogItem.category + ) + ) } } - fun deleteItem(item: ShoppingListItemEntity) { + /** 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 + ) + ) + } + } + + /** + * Tap sur un article actif → marque comme acheté (déplace dans Recently Used). + */ + fun markAsBought(id: Long) { + viewModelScope.launch { + manageListUseCase.setItemChecked(id, true) + } + } + + /** + * Tap sur un article dans Recently Used → restaure dans la liste active. + */ + fun restoreItem(id: Long) { + viewModelScope.launch { + manageListUseCase.setItemChecked(id, false) + } + } + + /** + * Suppression permanente d'un article (depuis la feuille de détail ou l'appui + * long sur Recently Used). + */ + fun deleteItem(id: Long) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch manageListUseCase.deleteItem(item) + if (_selectedItemId.value == id) _selectedItemId.value = null } } + /** Vide entièrement la section Recently Used (catégorie supprimée définitivement). */ + fun clearRecentlyUsed() { + viewModelScope.launch { + val listId = _listIdFlow.value + manageListUseCase.getItems(listId) + .filter { it.isChecked } + .forEach { manageListUseCase.deleteItem(it) } + } + } + + fun openItemDetails(id: Long) { + _selectedItemId.value = id + } + + fun closeItemDetails() { + _selectedItemId.value = null + } + + /** Met à jour la note (quantité / description) d'un article. */ + fun updateItemNote(id: Long, note: String) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch + manageListUseCase.updateItem(item.copy(note = note.trim().ifEmpty { null })) + } + } + + /** Change la catégorie/section d'un article. */ + fun updateItemCategory(id: Long, category: String) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch + manageListUseCase.updateItem(item.copy(category = category)) + } + } + + /** Déplace un article vers une autre liste. */ + fun moveItemToList(id: Long, targetListId: Long) { + viewModelScope.launch { + val sourceListId = _listIdFlow.value + if (targetListId == sourceListId) return@launch + val item = manageListUseCase.getItems(sourceListId).firstOrNull { it.id == id } ?: return@launch + manageListUseCase.deleteItem(item) + manageListUseCase.addItemToList( + targetListId, + item.copy(id = 0L, listId = targetListId, isChecked = false) + ) + if (_selectedItemId.value == id) _selectedItemId.value = null + } + } + + /** Décoche tous les articles (les réactive depuis Recently Used). */ fun uncheckAllItems() { viewModelScope.launch { manageListUseCase.uncheckAllItems(_listIdFlow.value) } } - fun addItemToList(item: ShoppingListItemEntity) { - viewModelScope.launch { - val listId = _listIdFlow.value - // Auto-categorisation - val category = categoryEngine.detectCategory(item.productName) - val itemWithCategory = item.copy( - listId = listId, - category = category - ) - manageListUseCase.addItemToList(listId, itemWithCategory) - } - } - - fun addProductFromHistory(product: RecentlyUsedProduct) { - viewModelScope.launch { - val listId = _listIdFlow.value - val category = categoryEngine.detectCategory(product.productName) - val item = ShoppingListItemEntity( - listId = listId, - barcode = product.barcode, - productName = product.productName, - brand = product.brand, - imageUrl = product.imageUrl, - category = category, - safetyStatus = product.safetyStatus - ) - manageListUseCase.addItemToList(listId, item) - } - } - - fun toggleSearch() { - _showSearch.value = !_showSearch.value - if (!_showSearch.value) { - _searchQuery.value = "" - } - } - - fun updateSearchQuery(query: String) { - _searchQuery.value = query - } - - fun mergeWithList(otherListId: Long, otherListName: String) { - viewModelScope.launch { - val listId = _listIdFlow.value - val items = manageListUseCase.getItems(otherListId) - items.forEach { item -> - val newItem = item.copy(id = 0L, listId = listId) - manageListUseCase.addItem(newItem) - } - } - } - fun shareList(listName: String, items: List): String { - val checkedCount = items.count { it.isChecked } - val totalCount = items.size - val uncheckedItems = items.filterNot { it.isChecked } - + val active = items.filterNot { it.isChecked } val sb = StringBuilder() sb.appendLine("📋 $listName") sb.appendLine("━━━━━━━━━━━━━━━━━━━━") - sb.appendLine("$checkedCount/$totalCount produits achetés") + sb.appendLine("${active.size} articles à acheter") sb.appendLine() - - val byCategory = uncheckedItems.groupBy { it.category ?: "Autre" } + val byCategory = active.groupBy { it.category ?: "Autre" } byCategory.forEach { (category, categoryItems) -> sb.appendLine("📂 $category") categoryItems.forEach { item -> - val status = when (item.safetyStatus) { - "SAFE" -> "✅" - "WARNING" -> "⚠️" - "DANGER" -> "❌" - else -> "☐" - } + val note = item.note?.let { " — $it" } ?: "" val warning = if (!item.allergenWarning.isNullOrBlank()) " ⚠️${item.allergenWarning}" else "" - sb.appendLine(" $status ${item.productName}${warning}") + sb.appendLine(" ☐ ${item.productName}${note}${warning}") } sb.appendLine() } - return sb.toString() } - private fun ScanHistoryItem.toRecentlyUsed() = RecentlyUsedProduct( - barcode = barcode, - productName = productName ?: "Produit inconnu", - brand = brand, - imageUrl = imageUrl, - safetyStatus = safetyStatus.name, - category = categoryEngine.detectCategory(productName ?: "") - ) + // ── Helpers internes ──────────────────────────────────────────────────── + + /** + * Sépare une saisie type "2 kg pommes" → (note="2 kg", name="pommes"). + * Retourne (null, raw) si aucun préfixe quantité détecté. + */ + internal fun parseQuantityAndName(raw: String): Pair { + val regex = Regex("""^(\d+([.,]\d+)?\s*(kg|g|l|ml|cl|pcs|pièces?|pieces?|x)?)\s+(.+)$""", RegexOption.IGNORE_CASE) + val match = regex.matchEntire(raw) + return if (match != null) { + val quantity = match.groupValues[1].trim() + val name = match.groupValues[4].trim().replaceFirstChar { it.uppercase() } + quantity to name + } else { + null to raw.replaceFirstChar { it.uppercase() } + } + } private fun ShoppingListItemEntity.toUi() = ShoppingListItemUi( id = id, @@ -239,6 +391,12 @@ class ListDetailViewModel @Inject constructor( isChecked = isChecked, category = category, safetyStatus = safetyStatus, - allergenWarning = allergenWarning + allergenWarning = allergenWarning, + note = note, + emoji = catalog.emojiFor(productName, category) ) + + companion object { + private const val MAX_RECENTLY_USED = 30 + } } diff --git a/version.properties b/version.properties index 876aa5b..b27d052 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ MAJOR=1 -MINOR=6 +MINOR=7 PATCH=0 -CODE=7 +CODE=8