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 category: String? = null, // "Frais", "Épicerie", etc.
|
||||||
val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER"
|
val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER"
|
||||||
val allergenWarning: String? = null, // Allergène détecté pour alerte
|
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()
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.data.local.database.entity.ShoppingListItemEntity
|
||||||
|
import com.safebite.app.domain.engine.CatalogProvider
|
||||||
import com.safebite.app.domain.engine.CategoryEngine
|
import com.safebite.app.domain.engine.CategoryEngine
|
||||||
import com.safebite.app.domain.model.ScanHistoryItem
|
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
||||||
import com.safebite.app.domain.usecase.GetScanHistoryUseCase
|
|
||||||
import com.safebite.app.domain.usecase.ManageShoppingListUseCase
|
import com.safebite.app.domain.usecase.ManageShoppingListUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@ -19,31 +20,36 @@ import kotlinx.coroutines.launch
|
|||||||
import javax.inject.Inject
|
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:
|
* Modèle de données :
|
||||||
* - L'affichage des produits dans la liste
|
* - **Articles actifs** : items de la liste avec `isChecked = false`. Affichés en
|
||||||
* - L'ajout de produits (manuel ou depuis l'historique)
|
* tuiles (rouges) en haut. Un tap → "marqué comme acheté" (déplace vers Recently
|
||||||
* - La recherche de produits
|
* Used). Appui long → feuille de détail.
|
||||||
* - Les catégories de produits
|
* - **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
|
@HiltViewModel
|
||||||
class ListDetailViewModel @Inject constructor(
|
class ListDetailViewModel @Inject constructor(
|
||||||
private val manageListUseCase: ManageShoppingListUseCase,
|
private val manageListUseCase: ManageShoppingListUseCase,
|
||||||
private val getScanHistoryUseCase: GetScanHistoryUseCase,
|
private val getListsUseCase: GetShoppingListsUseCase,
|
||||||
private val categoryEngine: CategoryEngine
|
private val categoryEngine: CategoryEngine,
|
||||||
|
val catalog: CatalogProvider
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
sealed class UiState {
|
sealed class UiState {
|
||||||
object Loading : UiState()
|
data object Loading : UiState()
|
||||||
data class Success(
|
data class Ready(
|
||||||
val listId: Long,
|
val listId: Long,
|
||||||
val listName: String,
|
val listName: String,
|
||||||
val items: List<ShoppingListItemUi>,
|
val activeItems: List<ShoppingListItemUi>,
|
||||||
val categories: List<String>,
|
val recentlyUsed: List<ShoppingListItemUi>
|
||||||
val recentlyUsed: List<RecentlyUsedProduct>
|
|
||||||
) : UiState()
|
) : UiState()
|
||||||
data class Empty(val listId: Long, val listName: String, val recentlyUsed: List<RecentlyUsedProduct>) : UiState()
|
|
||||||
data class Error(val message: String) : UiState()
|
data class Error(val message: String) : UiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,64 +62,74 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
val isChecked: Boolean,
|
val isChecked: Boolean,
|
||||||
val category: String?,
|
val category: String?,
|
||||||
val safetyStatus: String?,
|
val safetyStatus: String?,
|
||||||
val allergenWarning: String?
|
val allergenWarning: String?,
|
||||||
|
val note: String?,
|
||||||
|
val emoji: String
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RecentlyUsedProduct(
|
/**
|
||||||
val barcode: String,
|
* Suggestion affichée dans le panneau au-dessus de la barre de recherche.
|
||||||
val productName: String,
|
* Peut être un article existant (catalogue / recently used / actif) ou la
|
||||||
val brand: String?,
|
* proposition de création d'un nouvel article.
|
||||||
val imageUrl: String?,
|
*/
|
||||||
val safetyStatus: String?,
|
sealed class Suggestion {
|
||||||
val category: String?
|
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 val _listIdFlow = MutableStateFlow(0L)
|
||||||
private var _listName: String = ""
|
private val _listName = MutableStateFlow("")
|
||||||
|
|
||||||
private val _searchQuery = MutableStateFlow("")
|
private val _searchQuery = MutableStateFlow("")
|
||||||
val searchQuery: StateFlow<String> = _searchQuery
|
val searchQuery: StateFlow<String> = _searchQuery
|
||||||
|
|
||||||
private val _showSearch = MutableStateFlow(false)
|
/** Article actuellement ouvert dans la feuille de détail (long-press). */
|
||||||
val showSearch: StateFlow<Boolean> = _showSearch
|
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) {
|
fun initList(listId: Long, listName: String) {
|
||||||
_listIdFlow.value = listId
|
_listIdFlow.value = listId
|
||||||
_listName = listName
|
_listName.value = listName
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val state: StateFlow<UiState> = _listIdFlow.flatMapLatest { listId ->
|
val state: StateFlow<UiState> = _listIdFlow.flatMapLatest { listId ->
|
||||||
combine(
|
combine(
|
||||||
manageListUseCase.observeItems(listId),
|
manageListUseCase.observeItems(listId),
|
||||||
getScanHistoryUseCase.observe(),
|
_listName
|
||||||
_searchQuery
|
) { items, listName ->
|
||||||
) { items, history, query ->
|
val ui = items.map { it.toUi() }
|
||||||
// Convertir l'historique en produits récemment utilisés
|
UiState.Ready(
|
||||||
val recentlyUsed = history.take(20).map { it.toRecentlyUsed() }
|
listId = listId,
|
||||||
|
listName = listName,
|
||||||
// Filtrer les items selon la recherche
|
activeItems = ui.filterNot { it.isChecked }
|
||||||
val filteredItems = if (query.isBlank()) {
|
.sortedBy { it.productName.lowercase() },
|
||||||
items
|
recentlyUsed = ui.filter { it.isChecked }
|
||||||
} else {
|
// Most-recently bought first (proxy: addedAt ordering preserved by DAO)
|
||||||
items.filter {
|
.take(MAX_RECENTLY_USED)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@ -121,114 +137,250 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
initialValue = UiState.Loading
|
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 {
|
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 {
|
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)
|
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() {
|
fun uncheckAllItems() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
manageListUseCase.uncheckAllItems(_listIdFlow.value)
|
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 {
|
fun shareList(listName: String, items: List<ShoppingListItemUi>): String {
|
||||||
val checkedCount = items.count { it.isChecked }
|
val active = items.filterNot { it.isChecked }
|
||||||
val totalCount = items.size
|
|
||||||
val uncheckedItems = items.filterNot { it.isChecked }
|
|
||||||
|
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
sb.appendLine("📋 $listName")
|
sb.appendLine("📋 $listName")
|
||||||
sb.appendLine("━━━━━━━━━━━━━━━━━━━━")
|
sb.appendLine("━━━━━━━━━━━━━━━━━━━━")
|
||||||
sb.appendLine("$checkedCount/$totalCount produits achetés")
|
sb.appendLine("${active.size} articles à acheter")
|
||||||
sb.appendLine()
|
sb.appendLine()
|
||||||
|
val byCategory = active.groupBy { it.category ?: "Autre" }
|
||||||
val byCategory = uncheckedItems.groupBy { it.category ?: "Autre" }
|
|
||||||
byCategory.forEach { (category, categoryItems) ->
|
byCategory.forEach { (category, categoryItems) ->
|
||||||
sb.appendLine("📂 $category")
|
sb.appendLine("📂 $category")
|
||||||
categoryItems.forEach { item ->
|
categoryItems.forEach { item ->
|
||||||
val status = when (item.safetyStatus) {
|
val note = item.note?.let { " — $it" } ?: ""
|
||||||
"SAFE" -> "✅"
|
|
||||||
"WARNING" -> "⚠️"
|
|
||||||
"DANGER" -> "❌"
|
|
||||||
else -> "☐"
|
|
||||||
}
|
|
||||||
val warning = if (!item.allergenWarning.isNullOrBlank()) " ⚠️${item.allergenWarning}" else ""
|
val warning = if (!item.allergenWarning.isNullOrBlank()) " ⚠️${item.allergenWarning}" else ""
|
||||||
sb.appendLine(" $status ${item.productName}${warning}")
|
sb.appendLine(" ☐ ${item.productName}${note}${warning}")
|
||||||
}
|
}
|
||||||
sb.appendLine()
|
sb.appendLine()
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.toString()
|
return sb.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ScanHistoryItem.toRecentlyUsed() = RecentlyUsedProduct(
|
// ── Helpers internes ────────────────────────────────────────────────────
|
||||||
barcode = barcode,
|
|
||||||
productName = productName ?: "Produit inconnu",
|
/**
|
||||||
brand = brand,
|
* Sépare une saisie type "2 kg pommes" → (note="2 kg", name="pommes").
|
||||||
imageUrl = imageUrl,
|
* Retourne (null, raw) si aucun préfixe quantité détecté.
|
||||||
safetyStatus = safetyStatus.name,
|
*/
|
||||||
category = categoryEngine.detectCategory(productName ?: "")
|
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(
|
private fun ShoppingListItemEntity.toUi() = ShoppingListItemUi(
|
||||||
id = id,
|
id = id,
|
||||||
@ -239,6 +391,12 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
isChecked = isChecked,
|
isChecked = isChecked,
|
||||||
category = category,
|
category = category,
|
||||||
safetyStatus = safetyStatus,
|
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
|
MAJOR=1
|
||||||
MINOR=6
|
MINOR=7
|
||||||
PATCH=0
|
PATCH=0
|
||||||
CODE=7
|
CODE=8
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user