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:
parent
6ad4d64db1
commit
1656b189f4
@ -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()
|
||||
)
|
||||
|
||||
@ -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 -> "📦"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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,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<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(
|
||||
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<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
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
MAJOR=1
|
||||
MINOR=6
|
||||
MINOR=7
|
||||
PATCH=0
|
||||
CODE=7
|
||||
CODE=8
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user