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,
This commit is contained in:
Bruno Charest 2026-04-26 12:03:17 -04:00
parent 6ad4d64db1
commit 1656b189f4
5 changed files with 1405 additions and 748 deletions

View File

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

View File

@ -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<String> = 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<String> = 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<CatalogItem> = 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<CatalogItem> =
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<CatalogItem> {
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<CatalogItem> = 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 -> "📦"
}
}
}

View File

@ -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).
* ViewModel pour l'écran détail d'une liste refonte type Bring!.
*
* 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
* 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<ShoppingListItemUi>,
val categories: List<String>,
val recentlyUsed: List<RecentlyUsedProduct>
val activeItems: List<ShoppingListItemUi>,
val recentlyUsed: List<ShoppingListItemUi>
) : UiState()
data class Empty(val listId: Long, val listName: String, val recentlyUsed: List<RecentlyUsedProduct>) : UiState()
data class Error(val message: String) : UiState()
}
@ -56,179 +62,325 @@ 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<String> = _searchQuery
private val _showSearch = MutableStateFlow(false)
val showSearch: StateFlow<Boolean> = _showSearch
/** Article actuellement ouvert dans la feuille de détail (long-press). */
private val _selectedItemId = MutableStateFlow<Long?>(null)
val selectedItemId: StateFlow<Long?> = _selectedItemId
/** Listes disponibles pour l'action "Déplacer l'article". */
val otherLists: StateFlow<List<ShoppingListEntity>> = 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<UiState> = _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(
_listName
) { items, listName ->
val ui = items.map { it.toUi() }
UiState.Ready(
listId = listId,
listName = _listName,
items = filteredItems.map { it.toUi() },
categories = listOf("Tous") + categories,
recentlyUsed = recentlyUsed
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,
started = SharingStarted.WhileSubscribed(5000),
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<List<Suggestion>> = 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<Suggestion>()
// 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<ShoppingListItemUi>): 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<String?, String> {
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
}
}

View File

@ -1,4 +1,4 @@
MAJOR=1
MINOR=6
MINOR=7
PATCH=0
CODE=7
CODE=8