feat: add catalog domain/category browsing with seed data, implement navigation from shopping lists to catalog UI, add ProGuard rules for Moshi/Room
- Add `ShoppingDomainEntity`, `CategoryEntity`, `CatalogItemEntity`, `ItemCategoryCrossRef` tables to database - Increment database version to 8 with migration from v7 - Create `CatalogDao` with queries for domains, categories, items, and search - Implement `CatalogSeedManager` to parse and insert JSON seed data on first launch - Add catalog navigation routes
This commit is contained in:
parent
2cef0e399c
commit
bd9d01a6ee
17
app/proguard-rules.pro
vendored
17
app/proguard-rules.pro
vendored
@ -1,12 +1,27 @@
|
||||
# SafeBite proguard rules
|
||||
-keepattributes *Annotation*, Signature, Exceptions, InnerClasses
|
||||
|
||||
# Moshi
|
||||
# Moshi — runtime + codegen generated adapters + reflection fallback
|
||||
-keep class com.squareup.moshi.** { *; }
|
||||
-keep class kotlin.reflect.jvm.internal.** { *; }
|
||||
-keepclassmembers class ** {
|
||||
@com.squareup.moshi.FromJson *;
|
||||
@com.squareup.moshi.ToJson *;
|
||||
}
|
||||
# Conserver les adapters générés par Moshi codegen (ClassName + "JsonAdapter")
|
||||
-keep,allowobfuscation,allowshrinking class **JsonAdapter {
|
||||
<init>(...);
|
||||
<fields>;
|
||||
}
|
||||
-keep,allowobfuscation class kotlin.Metadata
|
||||
|
||||
# Catalogue : modèles de seed JSON + adapters générés
|
||||
-keep class com.safebite.app.data.local.seed.** { *; }
|
||||
-keep class com.safebite.app.data.local.seed.*JsonAdapter { *; }
|
||||
|
||||
# Room entities : conserver les noms de champs pour la réflexion Room
|
||||
-keep class com.safebite.app.data.local.database.entity.** { *; }
|
||||
-keep class com.safebite.app.data.local.database.relation.** { *; }
|
||||
|
||||
# Retrofit
|
||||
-keep class retrofit2.** { *; }
|
||||
|
||||
418
app/src/main/assets/catalog_seed.json
Normal file
418
app/src/main/assets/catalog_seed.json
Normal file
@ -0,0 +1,418 @@
|
||||
{"version":1,"domains":[
|
||||
{"domainId":"grocery","name":"Alimentation & Cuisine","emoji":"🏠","color":"#4CAF50","sortOrder":0,"categories":[
|
||||
{"categoryId":"fruits_vegetables","name":"Fruits & Légumes","emoji":"🥗","color":"#8BC34A","sortOrder":0,"items":[
|
||||
{"itemId":"pomme","name":"Pomme","emoji":"🍎","aliases":"apple","tags":"fruit"},
|
||||
{"itemId":"banane","name":"Banane","emoji":"🍌","aliases":"banana","tags":"fruit"},
|
||||
{"itemId":"orange","name":"Orange","emoji":"🍊","tags":"fruit"},
|
||||
{"itemId":"citron","name":"Citron","emoji":"🍋","aliases":"lemon","tags":"fruit"},
|
||||
{"itemId":"fraise","name":"Fraise","emoji":"🍓","aliases":"strawberry","tags":"fruit"},
|
||||
{"itemId":"raisin","name":"Raisin","emoji":"🍇","tags":"fruit"},
|
||||
{"itemId":"poire","name":"Poire","emoji":"🍐","tags":"fruit"},
|
||||
{"itemId":"mangue","name":"Mangue","emoji":"🥭","tags":"fruit"},
|
||||
{"itemId":"ananas","name":"Ananas","emoji":"🍍","tags":"fruit"},
|
||||
{"itemId":"kiwi","name":"Kiwi","emoji":"🥝","tags":"fruit"},
|
||||
{"itemId":"pasteque","name":"Pastèque","emoji":"🍉","aliases":"watermelon","tags":"fruit"},
|
||||
{"itemId":"bleuets","name":"Bleuets","emoji":"🫐","aliases":"myrtilles","tags":"fruit"},
|
||||
{"itemId":"tomate","name":"Tomate","emoji":"🍅","aliases":"tomato","tags":"legume"},
|
||||
{"itemId":"salade","name":"Salade","emoji":"🥬","aliases":"laitue","tags":"legume"},
|
||||
{"itemId":"carotte","name":"Carotte","emoji":"🥕","aliases":"carrot","tags":"legume"},
|
||||
{"itemId":"brocoli","name":"Brocoli","emoji":"🥦","tags":"legume"},
|
||||
{"itemId":"concombre","name":"Concombre","emoji":"🥒","aliases":"cucumber","tags":"legume"},
|
||||
{"itemId":"poivron","name":"Poivron","emoji":"🫑","tags":"legume"},
|
||||
{"itemId":"avocat","name":"Avocat","emoji":"🥑","aliases":"avocado","tags":"legume"},
|
||||
{"itemId":"oignon","name":"Oignon","emoji":"🧅","aliases":"onion","tags":"legume"},
|
||||
{"itemId":"ail","name":"Ail","emoji":"🧄","aliases":"garlic","tags":"legume"},
|
||||
{"itemId":"pomme_de_terre","name":"Pomme de terre","emoji":"🥔","aliases":"patate","tags":"legume"},
|
||||
{"itemId":"champignon","name":"Champignon","emoji":"🍄","tags":"legume"},
|
||||
{"itemId":"epinard","name":"Épinard","emoji":"🥬","aliases":"spinach","tags":"legume"},
|
||||
{"itemId":"mais","name":"Maïs","emoji":"🌽","aliases":"corn","tags":"legume"},
|
||||
{"itemId":"basilic","name":"Basilic","emoji":"🌿","aliases":"basil","tags":"herbe"},
|
||||
{"itemId":"persil","name":"Persil","emoji":"🌿","aliases":"parsley","tags":"herbe"}
|
||||
]},
|
||||
{"categoryId":"bakery","name":"Boulangerie & Pâtisserie","emoji":"🥖","color":"#D4A574","sortOrder":1,"items":[
|
||||
{"itemId":"pain_blanc","name":"Pain blanc","emoji":"🍞","tags":"pain"},
|
||||
{"itemId":"pain_de_ble","name":"Pain de blé","emoji":"🍞","tags":"pain"},
|
||||
{"itemId":"baguette","name":"Baguette","emoji":"🥖","tags":"pain"},
|
||||
{"itemId":"croissant","name":"Croissant","emoji":"🥐","tags":"viennoiserie"},
|
||||
{"itemId":"muffins","name":"Muffins","emoji":"🧁","tags":"patisserie"},
|
||||
{"itemId":"bagels","name":"Bagels","emoji":"🥯","tags":"pain"},
|
||||
{"itemId":"tortillas","name":"Tortillas","emoji":"🌯","tags":"pain"},
|
||||
{"itemId":"pain_hamburger","name":"Pain hamburger","emoji":"🍔","tags":"pain"},
|
||||
{"itemId":"naan","name":"Naan","emoji":"🫓","tags":"pain"},
|
||||
{"itemId":"gateau","name":"Gâteau","emoji":"🍰","aliases":"cake","tags":"patisserie"},
|
||||
{"itemId":"tarte","name":"Tarte","emoji":"🥧","tags":"patisserie"},
|
||||
{"itemId":"beignes","name":"Beignes","emoji":"🍩","aliases":"donuts","tags":"patisserie"}
|
||||
]},
|
||||
{"categoryId":"dairy","name":"Produits Laitiers","emoji":"🥛","color":"#FFF8E1","sortOrder":2,"items":[
|
||||
{"itemId":"lait","name":"Lait","emoji":"🥛","aliases":"milk","tags":"laitier"},
|
||||
{"itemId":"lait_amande","name":"Lait d'amande","emoji":"🥛","tags":"laitier"},
|
||||
{"itemId":"creme","name":"Crème","emoji":"🥛","aliases":"cream","tags":"laitier"},
|
||||
{"itemId":"beurre","name":"Beurre","emoji":"🧈","aliases":"butter","tags":"laitier"},
|
||||
{"itemId":"margarine","name":"Margarine","emoji":"🧈","tags":"laitier"},
|
||||
{"itemId":"fromage_cheddar","name":"Fromage cheddar","emoji":"🧀","tags":"fromage"},
|
||||
{"itemId":"fromage_mozzarella","name":"Mozzarella","emoji":"🧀","tags":"fromage"},
|
||||
{"itemId":"fromage_brie","name":"Brie","emoji":"🧀","tags":"fromage"},
|
||||
{"itemId":"fromage_feta","name":"Feta","emoji":"🧀","tags":"fromage"},
|
||||
{"itemId":"yaourt","name":"Yaourt","emoji":"🥣","aliases":"yogurt","tags":"laitier"},
|
||||
{"itemId":"yaourt_grec","name":"Yaourt grec","emoji":"🥣","tags":"laitier"},
|
||||
{"itemId":"oeufs","name":"Œufs","emoji":"🥚","aliases":"eggs","tags":"laitier"}
|
||||
]},
|
||||
{"categoryId":"meat_fish","name":"Viandes & Poissons","emoji":"🥩","color":"#EF5350","sortOrder":3,"items":[
|
||||
{"itemId":"poulet","name":"Poulet","emoji":"🍗","aliases":"chicken","tags":"viande"},
|
||||
{"itemId":"boeuf_hache","name":"Bœuf haché","emoji":"🥩","aliases":"ground beef","tags":"viande"},
|
||||
{"itemId":"steak","name":"Steak","emoji":"🥩","tags":"viande"},
|
||||
{"itemId":"porc","name":"Porc","emoji":"🥓","aliases":"pork","tags":"viande"},
|
||||
{"itemId":"bacon","name":"Bacon","emoji":"🥓","tags":"viande"},
|
||||
{"itemId":"jambon","name":"Jambon","emoji":"🥓","aliases":"ham","tags":"viande"},
|
||||
{"itemId":"saucisses","name":"Saucisses","emoji":"🌭","tags":"viande"},
|
||||
{"itemId":"dinde","name":"Dinde","emoji":"🦃","aliases":"turkey","tags":"viande"},
|
||||
{"itemId":"saumon","name":"Saumon","emoji":"🐟","aliases":"salmon","tags":"poisson"},
|
||||
{"itemId":"thon","name":"Thon","emoji":"🐟","aliases":"tuna","tags":"poisson"},
|
||||
{"itemId":"crevettes","name":"Crevettes","emoji":"🦐","aliases":"shrimp","tags":"poisson"},
|
||||
{"itemId":"tofu","name":"Tofu","emoji":"🥡","tags":"vegetal"}
|
||||
]},
|
||||
{"categoryId":"pantry","name":"Épicerie & Garde-manger","emoji":"🛒","color":"#FFB74D","sortOrder":4,"items":[
|
||||
{"itemId":"pates","name":"Pâtes","emoji":"🍝","aliases":"pasta","tags":"pates"},
|
||||
{"itemId":"riz","name":"Riz","emoji":"🍚","aliases":"rice","tags":"feculent"},
|
||||
{"itemId":"farine","name":"Farine","emoji":"🌾","aliases":"flour","tags":"boulangerie"},
|
||||
{"itemId":"sucre","name":"Sucre","emoji":"🍬","aliases":"sugar","tags":"boulangerie"},
|
||||
{"itemId":"huile_olive","name":"Huile d'olive","emoji":"🫒","tags":"huile"},
|
||||
{"itemId":"vinaigre","name":"Vinaigre","emoji":"🧴","tags":"condiment"},
|
||||
{"itemId":"sauce_tomate","name":"Sauce tomate","emoji":"🍅","tags":"condiment"},
|
||||
{"itemId":"ketchup","name":"Ketchup","emoji":"🍅","tags":"condiment"},
|
||||
{"itemId":"moutarde","name":"Moutarde","emoji":"🟡","tags":"condiment"},
|
||||
{"itemId":"miel","name":"Miel","emoji":"🍯","aliases":"honey","tags":"sucre"},
|
||||
{"itemId":"sirop_erable","name":"Sirop d'érable","emoji":"🍯","tags":"sucre"},
|
||||
{"itemId":"confiture","name":"Confiture","emoji":"🍓","aliases":"jam","tags":"tartinade"},
|
||||
{"itemId":"beurre_arachide","name":"Beurre d'arachide","emoji":"🥜","tags":"tartinade"},
|
||||
{"itemId":"cereales","name":"Céréales","emoji":"🥣","tags":"dejeuner"}
|
||||
]},
|
||||
{"categoryId":"spices","name":"Condiments & Épices","emoji":"🌶️","color":"#FF7043","sortOrder":5,"items":[
|
||||
{"itemId":"sel","name":"Sel","emoji":"🧂","aliases":"salt","tags":"epice"},
|
||||
{"itemId":"poivre","name":"Poivre","emoji":"🧂","aliases":"pepper","tags":"epice"},
|
||||
{"itemId":"paprika","name":"Paprika","emoji":"🌶️","tags":"epice"},
|
||||
{"itemId":"cumin","name":"Cumin","emoji":"🌶️","tags":"epice"},
|
||||
{"itemId":"curry","name":"Curry","emoji":"🌶️","tags":"epice"},
|
||||
{"itemId":"cannelle","name":"Cannelle","emoji":"🌶️","aliases":"cinnamon","tags":"epice"},
|
||||
{"itemId":"origan","name":"Origan","emoji":"🌿","tags":"herbe"},
|
||||
{"itemId":"thym","name":"Thym","emoji":"🌿","tags":"herbe"},
|
||||
{"itemId":"romarin","name":"Romarin","emoji":"🌿","tags":"herbe"}
|
||||
]},
|
||||
{"categoryId":"frozen","name":"Surgelés","emoji":"🧊","color":"#90CAF9","sortOrder":6,"items":[
|
||||
{"itemId":"pizza_surgelee","name":"Pizza surgelée","emoji":"🍕","tags":"surgele"},
|
||||
{"itemId":"frites_surgelees","name":"Frites surgelées","emoji":"🍟","tags":"surgele"},
|
||||
{"itemId":"creme_glacee","name":"Crème glacée","emoji":"🍨","aliases":"ice cream","tags":"surgele"},
|
||||
{"itemId":"legumes_surgeles","name":"Légumes surgelés","emoji":"🥦","tags":"surgele"},
|
||||
{"itemId":"nuggets","name":"Nuggets","emoji":"🍗","tags":"surgele"}
|
||||
]},
|
||||
{"categoryId":"snacks","name":"Snacks & Confiseries","emoji":"🍿","color":"#FFCA28","sortOrder":7,"items":[
|
||||
{"itemId":"chips","name":"Chips","emoji":"🥔","aliases":"croustilles","tags":"snack"},
|
||||
{"itemId":"popcorn","name":"Pop-corn","emoji":"🍿","tags":"snack"},
|
||||
{"itemId":"chocolat","name":"Chocolat","emoji":"🍫","tags":"chocolat"},
|
||||
{"itemId":"biscuits","name":"Biscuits","emoji":"🍪","aliases":"cookies","tags":"snack"},
|
||||
{"itemId":"bonbons","name":"Bonbons","emoji":"🍬","aliases":"candy","tags":"confiserie"},
|
||||
{"itemId":"gomme","name":"Gomme","emoji":"🍬","tags":"confiserie"}
|
||||
]},
|
||||
{"categoryId":"beverages","name":"Boissons","emoji":"🥤","color":"#42A5F5","sortOrder":8,"items":[
|
||||
{"itemId":"eau","name":"Eau","emoji":"💧","aliases":"water","tags":"boisson"},
|
||||
{"itemId":"jus_orange","name":"Jus d'orange","emoji":"🧃","tags":"jus"},
|
||||
{"itemId":"jus_pomme","name":"Jus de pomme","emoji":"🧃","tags":"jus"},
|
||||
{"itemId":"coca_cola","name":"Coca-Cola","emoji":"🥤","aliases":"coke","tags":"soda"},
|
||||
{"itemId":"cafe","name":"Café","emoji":"☕","aliases":"coffee","tags":"cafe"},
|
||||
{"itemId":"the","name":"Thé","emoji":"🍵","aliases":"tea","tags":"the"},
|
||||
{"itemId":"biere","name":"Bière","emoji":"🍺","aliases":"beer","tags":"alcool"},
|
||||
{"itemId":"vin_rouge","name":"Vin rouge","emoji":"🍷","tags":"alcool"}
|
||||
]}
|
||||
]},
|
||||
{"domainId":"pharmacy","name":"Santé & Pharmacie","emoji":"💊","color":"#E91E63","sortOrder":1,"categories":[
|
||||
{"categoryId":"medications","name":"Médicaments","emoji":"💊","color":"#EF5350","sortOrder":0,"items":[
|
||||
{"itemId":"acetaminophene","name":"Acétaminophène","emoji":"💊","aliases":"tylenol","tags":"medicament"},
|
||||
{"itemId":"ibuprofene","name":"Ibuprofène","emoji":"💊","aliases":"advil","tags":"medicament"},
|
||||
{"itemId":"aspirine","name":"Aspirine","emoji":"💊","tags":"medicament"},
|
||||
{"itemId":"sirop_toux","name":"Sirop contre la toux","emoji":"💊","tags":"rhume"},
|
||||
{"itemId":"antihistaminique","name":"Antihistaminique","emoji":"💊","aliases":"benadryl","tags":"allergie"},
|
||||
{"itemId":"pansements","name":"Pansements","emoji":"🩹","aliases":"band-aid","tags":"premiers_soins"},
|
||||
{"itemId":"thermometre","name":"Thermomètre","emoji":"🌡️","tags":"medical"}
|
||||
]},
|
||||
{"categoryId":"hygiene","name":"Hygiène","emoji":"🧴","color":"#26C6DA","sortOrder":1,"items":[
|
||||
{"itemId":"savon","name":"Savon","emoji":"🧼","aliases":"soap","tags":"hygiene"},
|
||||
{"itemId":"shampooing","name":"Shampooing","emoji":"🧴","aliases":"shampoo","tags":"hygiene"},
|
||||
{"itemId":"revitalisant","name":"Revitalisant","emoji":"🧴","aliases":"conditioner","tags":"hygiene"},
|
||||
{"itemId":"dentifrice","name":"Dentifrice","emoji":"🦷","aliases":"toothpaste","tags":"hygiene"},
|
||||
{"itemId":"brosse_dents","name":"Brosse à dents","emoji":"🪥","tags":"hygiene"},
|
||||
{"itemId":"deodorant","name":"Déodorant","emoji":"🧴","tags":"hygiene"},
|
||||
{"itemId":"rasoir","name":"Rasoir","emoji":"🪒","tags":"hygiene"},
|
||||
{"itemId":"papier_mouchoir","name":"Papier mouchoir","emoji":"🤧","aliases":"kleenex","tags":"hygiene"}
|
||||
]},
|
||||
{"categoryId":"baby_care","name":"Bébé","emoji":"👶","color":"#FFCDD2","sortOrder":2,"items":[
|
||||
{"itemId":"couches","name":"Couches","emoji":"👶","aliases":"diapers","tags":"bebe"},
|
||||
{"itemId":"lingettes_bebe","name":"Lingettes bébé","emoji":"🧻","tags":"bebe"},
|
||||
{"itemId":"lait_maternise","name":"Lait maternisé","emoji":"🍼","aliases":"formula","tags":"bebe"},
|
||||
{"itemId":"biberons","name":"Biberons","emoji":"🍼","tags":"bebe"},
|
||||
{"itemId":"purees_bebe","name":"Purée bébé","emoji":"🍎","tags":"bebe"}
|
||||
]},
|
||||
{"categoryId":"supplements","name":"Vitamines","emoji":"💪","color":"#FFB300","sortOrder":3,"items":[
|
||||
{"itemId":"multivitamines","name":"Multivitamines","emoji":"💊","tags":"vitamine"},
|
||||
{"itemId":"vitamine_c","name":"Vitamine C","emoji":"💊","tags":"vitamine"},
|
||||
{"itemId":"vitamine_d","name":"Vitamine D","emoji":"💊","tags":"vitamine"},
|
||||
{"itemId":"omega_3","name":"Oméga-3","emoji":"💊","tags":"supplement"},
|
||||
{"itemId":"probiotiques","name":"Probiotiques","emoji":"💊","tags":"supplement"}
|
||||
]}
|
||||
]},
|
||||
{"domainId":"hardware","name":"Rénovation & Bricolage","emoji":"🔧","color":"#FF9800","sortOrder":2,"categories":[
|
||||
{"categoryId":"tools","name":"Outillage","emoji":"🔨","color":"#FFA726","sortOrder":0,"items":[
|
||||
{"itemId":"marteau","name":"Marteau","emoji":"🔨","aliases":"hammer","tags":"outil"},
|
||||
{"itemId":"tournevis","name":"Tournevis","emoji":"🔧","aliases":"screwdriver","tags":"outil"},
|
||||
{"itemId":"perceuse","name":"Perceuse","emoji":"🔧","aliases":"drill","tags":"outil"},
|
||||
{"itemId":"scie","name":"Scie","emoji":"🪚","aliases":"saw","tags":"outil"},
|
||||
{"itemId":"niveau","name":"Niveau","emoji":"📏","tags":"outil"},
|
||||
{"itemId":"ruban_mesurer","name":"Ruban à mesurer","emoji":"📏","tags":"outil"},
|
||||
{"itemId":"pinces","name":"Pinces","emoji":"🔧","aliases":"pliers","tags":"outil"},
|
||||
{"itemId":"escabeau","name":"Escabeau","emoji":"🪜","tags":"outil"},
|
||||
{"itemId":"boite_outils","name":"Boîte à outils","emoji":"🧰","tags":"outil"}
|
||||
]},
|
||||
{"categoryId":"hardware_supplies","name":"Quincaillerie","emoji":"🔩","color":"#FB8C00","sortOrder":1,"items":[
|
||||
{"itemId":"vis","name":"Vis","emoji":"🔩","aliases":"screws","tags":"fixation"},
|
||||
{"itemId":"clous","name":"Clous","emoji":"🔩","aliases":"nails","tags":"fixation"},
|
||||
{"itemId":"chevilles","name":"Chevilles","emoji":"🔩","tags":"fixation"},
|
||||
{"itemId":"colle","name":"Colle","emoji":"🧴","aliases":"glue","tags":"adhesif"},
|
||||
{"itemId":"duct_tape","name":"Ruban adhésif","emoji":"🩹","tags":"adhesif"},
|
||||
{"itemId":"wd40","name":"WD-40","emoji":"🧴","tags":"lubrifiant"}
|
||||
]},
|
||||
{"categoryId":"paint","name":"Peinture","emoji":"🎨","color":"#AB47BC","sortOrder":2,"items":[
|
||||
{"itemId":"peinture","name":"Peinture","emoji":"🎨","aliases":"paint","tags":"peinture"},
|
||||
{"itemId":"pinceau","name":"Pinceau","emoji":"🖌️","tags":"peinture"},
|
||||
{"itemId":"rouleau","name":"Rouleau","emoji":"🖌️","tags":"peinture"},
|
||||
{"itemId":"papier_sable","name":"Papier sablé","emoji":"📄","aliases":"sandpaper","tags":"peinture"}
|
||||
]},
|
||||
{"categoryId":"electrical","name":"Électricité","emoji":"⚡","color":"#FFEE58","sortOrder":3,"items":[
|
||||
{"itemId":"ampoule_led","name":"Ampoule LED","emoji":"💡","tags":"eclairage"},
|
||||
{"itemId":"prise_electrique","name":"Prise","emoji":"🔌","tags":"electrique"},
|
||||
{"itemId":"interrupteur","name":"Interrupteur","emoji":"💡","tags":"electrique"},
|
||||
{"itemId":"rallonge","name":"Rallonge","emoji":"🔌","tags":"electrique"}
|
||||
]},
|
||||
{"categoryId":"garden","name":"Jardin","emoji":"🌱","color":"#7CB342","sortOrder":4,"items":[
|
||||
{"itemId":"terreau","name":"Terreau","emoji":"🌱","tags":"jardin"},
|
||||
{"itemId":"engrais","name":"Engrais","emoji":"🌱","tags":"jardin"},
|
||||
{"itemId":"pots_fleurs","name":"Pots de fleurs","emoji":"🪴","tags":"jardin"},
|
||||
{"itemId":"boyau_arrosage","name":"Boyau d'arrosage","emoji":"🌱","tags":"jardin"},
|
||||
{"itemId":"bbq","name":"BBQ","emoji":"🍖","tags":"exterieur"},
|
||||
{"itemId":"propane","name":"Propane","emoji":"🔥","tags":"exterieur"}
|
||||
]}
|
||||
]},
|
||||
{"domainId":"technology","name":"Technologie","emoji":"💻","color":"#2196F3","sortOrder":3,"categories":[
|
||||
{"categoryId":"computers","name":"Informatique","emoji":"💻","color":"#42A5F5","sortOrder":0,"items":[
|
||||
{"itemId":"ordi_portable","name":"Ordinateur portable","emoji":"💻","aliases":"laptop","tags":"tech"},
|
||||
{"itemId":"clavier","name":"Clavier","emoji":"⌨️","tags":"tech"},
|
||||
{"itemId":"souris","name":"Souris","emoji":"🖱️","tags":"tech"},
|
||||
{"itemId":"casque_ecoute","name":"Casque d'écoute","emoji":"🎧","tags":"tech"},
|
||||
{"itemId":"cle_usb","name":"Clé USB","emoji":"💾","tags":"tech"},
|
||||
{"itemId":"cable_hdmi","name":"Câble HDMI","emoji":"🔌","tags":"tech"},
|
||||
{"itemId":"imprimante","name":"Imprimante","emoji":"🖨️","tags":"tech"}
|
||||
]},
|
||||
{"categoryId":"phones","name":"Téléphonie","emoji":"📱","color":"#5C6BC0","sortOrder":1,"items":[
|
||||
{"itemId":"etui_telephone","name":"Étui de téléphone","emoji":"📱","tags":"tech"},
|
||||
{"itemId":"chargeur","name":"Chargeur","emoji":"🔌","tags":"tech"},
|
||||
{"itemId":"power_bank","name":"Batterie externe","emoji":"🔋","tags":"tech"},
|
||||
{"itemId":"ecouteurs_sans_fil","name":"Écouteurs sans fil","emoji":"🎧","aliases":"airpods","tags":"tech"}
|
||||
]},
|
||||
{"categoryId":"appliances","name":"Électroménager","emoji":"🍳","color":"#78909C","sortOrder":2,"items":[
|
||||
{"itemId":"refrigerateur","name":"Réfrigérateur","emoji":"🧊","aliases":"fridge","tags":"electromenager"},
|
||||
{"itemId":"micro_ondes","name":"Micro-ondes","emoji":"📡","tags":"electromenager"},
|
||||
{"itemId":"cafetiere","name":"Cafetière","emoji":"☕","tags":"electromenager"},
|
||||
{"itemId":"bouilloire","name":"Bouilloire","emoji":"☕","tags":"electromenager"},
|
||||
{"itemId":"grille_pain","name":"Grille-pain","emoji":"🍞","aliases":"toaster","tags":"electromenager"},
|
||||
{"itemId":"aspirateur","name":"Aspirateur","emoji":"🧹","tags":"electromenager"},
|
||||
{"itemId":"ventilateur","name":"Ventilateur","emoji":"💨","tags":"electromenager"}
|
||||
]},
|
||||
{"categoryId":"batteries_power","name":"Piles","emoji":"🔋","color":"#FFA726","sortOrder":3,"items":[
|
||||
{"itemId":"piles_aa","name":"Piles AA","emoji":"🔋","tags":"pile"},
|
||||
{"itemId":"piles_aaa","name":"Piles AAA","emoji":"🔋","tags":"pile"},
|
||||
{"itemId":"pile_9v","name":"Pile 9V","emoji":"🔋","tags":"pile"}
|
||||
]}
|
||||
]},
|
||||
{"domainId":"clothing","name":"Mode","emoji":"👕","color":"#9C27B0","sortOrder":4,"categories":[
|
||||
{"categoryId":"men_clothing","name":"Vêtements Homme","emoji":"👔","color":"#5C6BC0","sortOrder":0,"items":[
|
||||
{"itemId":"tshirt_homme","name":"T-shirt","emoji":"👕","tags":"mode"},
|
||||
{"itemId":"chemise","name":"Chemise","emoji":"👔","tags":"mode"},
|
||||
{"itemId":"pantalon_homme","name":"Pantalon","emoji":"👖","tags":"mode"},
|
||||
{"itemId":"jeans_homme","name":"Jeans","emoji":"👖","tags":"mode"},
|
||||
{"itemId":"chaussettes","name":"Chaussettes","emoji":"🧦","tags":"mode"}
|
||||
]},
|
||||
{"categoryId":"women_clothing","name":"Vêtements Femme","emoji":"👗","color":"#EC407A","sortOrder":1,"items":[
|
||||
{"itemId":"robe","name":"Robe","emoji":"👗","tags":"mode"},
|
||||
{"itemId":"jupe","name":"Jupe","emoji":"👗","tags":"mode"},
|
||||
{"itemId":"blouse","name":"Blouse","emoji":"👚","tags":"mode"},
|
||||
{"itemId":"legging","name":"Legging","emoji":"👖","tags":"mode"}
|
||||
]},
|
||||
{"categoryId":"shoes","name":"Chaussures","emoji":"👟","color":"#8D6E63","sortOrder":2,"items":[
|
||||
{"itemId":"souliers_course","name":"Souliers de course","emoji":"👟","tags":"chaussure"},
|
||||
{"itemId":"bottes_hiver","name":"Bottes d'hiver","emoji":"🥾","tags":"chaussure"},
|
||||
{"itemId":"sandales","name":"Sandales","emoji":"👡","tags":"chaussure"}
|
||||
]},
|
||||
{"categoryId":"accessories","name":"Accessoires","emoji":"👜","color":"#AB47BC","sortOrder":3,"items":[
|
||||
{"itemId":"sac_main","name":"Sac à main","emoji":"👜","tags":"accessoire"},
|
||||
{"itemId":"montre","name":"Montre","emoji":"⌚","tags":"accessoire"},
|
||||
{"itemId":"lunettes_soleil","name":"Lunettes de soleil","emoji":"🕶️","tags":"accessoire"},
|
||||
{"itemId":"sac_a_dos","name":"Sac à dos","emoji":"🎒","tags":"accessoire"}
|
||||
]}
|
||||
]},
|
||||
{"domainId":"home","name":"Maison & Décoration","emoji":"🏡","color":"#795548","sortOrder":5,"categories":[
|
||||
{"categoryId":"furniture","name":"Meubles","emoji":"🛋️","color":"#A1887F","sortOrder":0,"items":[
|
||||
{"itemId":"canape","name":"Canapé","emoji":"🛋️","aliases":"sofa","tags":"meuble"},
|
||||
{"itemId":"chaises","name":"Chaises","emoji":"🪑","tags":"meuble"},
|
||||
{"itemId":"table_manger","name":"Table à manger","emoji":"🪑","tags":"meuble"},
|
||||
{"itemId":"lit","name":"Lit","emoji":"🛏️","tags":"meuble"},
|
||||
{"itemId":"matelas","name":"Matelas","emoji":"🛏️","tags":"meuble"}
|
||||
]},
|
||||
{"categoryId":"decor","name":"Décoration","emoji":"🖼️","color":"#FFCA28","sortOrder":1,"items":[
|
||||
{"itemId":"cadre_photo","name":"Cadre photo","emoji":"🖼️","tags":"decor"},
|
||||
{"itemId":"miroir","name":"Miroir","emoji":"🪞","tags":"decor"},
|
||||
{"itemId":"bougie","name":"Bougie","emoji":"🕯️","tags":"decor"},
|
||||
{"itemId":"coussin","name":"Coussin","emoji":"🛋️","tags":"decor"},
|
||||
{"itemId":"tapis","name":"Tapis","emoji":"🟫","tags":"decor"},
|
||||
{"itemId":"rideau","name":"Rideau","emoji":"🪟","tags":"decor"},
|
||||
{"itemId":"lampe","name":"Lampe","emoji":"💡","tags":"decor"}
|
||||
]},
|
||||
{"categoryId":"kitchen_tools","name":"Ustensiles","emoji":"🍳","color":"#FF7043","sortOrder":2,"items":[
|
||||
{"itemId":"casserole","name":"Casserole","emoji":"🍲","tags":"ustensile"},
|
||||
{"itemId":"poele","name":"Poêle","emoji":"🍳","tags":"ustensile"},
|
||||
{"itemId":"couteau_chef","name":"Couteau de chef","emoji":"🔪","tags":"ustensile"},
|
||||
{"itemId":"planche_decouper","name":"Planche à découper","emoji":"🔪","tags":"ustensile"},
|
||||
{"itemId":"spatule","name":"Spatule","emoji":"🍳","tags":"ustensile"}
|
||||
]},
|
||||
{"categoryId":"bedding","name":"Literie & Salle de Bain","emoji":"🛏️","color":"#B39DDB","sortOrder":3,"items":[
|
||||
{"itemId":"draps","name":"Draps","emoji":"🛏️","tags":"literie"},
|
||||
{"itemId":"oreiller","name":"Oreiller","emoji":"🛏️","tags":"literie"},
|
||||
{"itemId":"couette","name":"Couette","emoji":"🛏️","tags":"literie"},
|
||||
{"itemId":"serviettes_bain","name":"Serviettes de bain","emoji":"🛁","tags":"salle_de_bain"}
|
||||
]}
|
||||
]},
|
||||
{"domainId":"pets","name":"Animaux","emoji":"🐾","color":"#8BC34A","sortOrder":6,"categories":[
|
||||
{"categoryId":"pet_food","name":"Alimentation","emoji":"🐕","color":"#9CCC65","sortOrder":0,"items":[
|
||||
{"itemId":"croquettes_chien","name":"Croquettes chien","emoji":"🐶","tags":"animal"},
|
||||
{"itemId":"croquettes_chat","name":"Croquettes chat","emoji":"🐱","tags":"animal"},
|
||||
{"itemId":"nourriture_humide_chat","name":"Nourriture humide chat","emoji":"🐈","tags":"animal"},
|
||||
{"itemId":"gateries_chien","name":"Gâteries chien","emoji":"🦴","tags":"animal"}
|
||||
]},
|
||||
{"categoryId":"pet_accessories","name":"Accessoires","emoji":"🎾","color":"#AED581","sortOrder":1,"items":[
|
||||
{"itemId":"laisse","name":"Laisse","emoji":"🐕","aliases":"leash","tags":"animal"},
|
||||
{"itemId":"collier_animal","name":"Collier","emoji":"🐕","tags":"animal"},
|
||||
{"itemId":"bol_animal","name":"Bol","emoji":"🥣","tags":"animal"},
|
||||
{"itemId":"jouet_animal","name":"Jouet","emoji":"🎾","tags":"animal"},
|
||||
{"itemId":"litiere","name":"Litière","emoji":"🐈","tags":"animal"}
|
||||
]}
|
||||
]},
|
||||
{"domainId":"auto","name":"Auto","emoji":"🚗","color":"#607D8B","sortOrder":7,"categories":[
|
||||
{"categoryId":"auto_maintenance","name":"Entretien Auto","emoji":"🔧","color":"#78909C","sortOrder":0,"items":[
|
||||
{"itemId":"huile_moteur","name":"Huile moteur","emoji":"🛢️","tags":"auto"},
|
||||
{"itemId":"liquide_lave_glace","name":"Liquide lave-glace","emoji":"🧴","tags":"auto"},
|
||||
{"itemId":"antigel","name":"Antigel","emoji":"🧴","tags":"auto"},
|
||||
{"itemId":"essuie_glace","name":"Essuie-glace","emoji":"🚗","tags":"auto"},
|
||||
{"itemId":"grattoir","name":"Grattoir à glace","emoji":"❄️","tags":"auto"}
|
||||
]},
|
||||
{"categoryId":"auto_accessories","name":"Accessoires Auto","emoji":"🚙","color":"#90A4AE","sortOrder":1,"items":[
|
||||
{"itemId":"chargeur_auto","name":"Chargeur auto","emoji":"🔌","tags":"auto"},
|
||||
{"itemId":"siege_auto_enfant","name":"Siège d'auto enfant","emoji":"👶","tags":"auto"},
|
||||
{"itemId":"tapis_auto","name":"Tapis d'auto","emoji":"🚗","tags":"auto"}
|
||||
]}
|
||||
]},
|
||||
{"domainId":"leisure","name":"Loisirs & Sports","emoji":"⚽","color":"#FF5722","sortOrder":8,"categories":[
|
||||
{"categoryId":"sports","name":"Articles de Sport","emoji":"⚽","color":"#FF7043","sortOrder":0,"items":[
|
||||
{"itemId":"ballon_soccer","name":"Ballon de soccer","emoji":"⚽","tags":"sport"},
|
||||
{"itemId":"tapis_yoga","name":"Tapis de yoga","emoji":"🧘","tags":"sport"},
|
||||
{"itemId":"halteres","name":"Haltères","emoji":"🏋️","tags":"sport"},
|
||||
{"itemId":"bouteille_sport","name":"Bouteille de sport","emoji":"🍶","tags":"sport"},
|
||||
{"itemId":"corde_a_sauter","name":"Corde à sauter","emoji":"🤸","tags":"sport"}
|
||||
]},
|
||||
{"categoryId":"camping","name":"Camping","emoji":"⛺","color":"#8BC34A","sortOrder":1,"items":[
|
||||
{"itemId":"tente","name":"Tente","emoji":"⛺","tags":"camping"},
|
||||
{"itemId":"sac_couchage","name":"Sac de couchage","emoji":"🛌","tags":"camping"},
|
||||
{"itemId":"lampe_frontale","name":"Lampe frontale","emoji":"🔦","tags":"camping"},
|
||||
{"itemId":"glaciere","name":"Glacière","emoji":"🧊","aliases":"cooler","tags":"camping"},
|
||||
{"itemId":"insectifuge","name":"Insectifuge","emoji":"🦟","tags":"camping"}
|
||||
]},
|
||||
{"categoryId":"books_stationery","name":"Livres & Papeterie","emoji":"📚","color":"#7986CB","sortOrder":2,"items":[
|
||||
{"itemId":"livre","name":"Livre","emoji":"📚","aliases":"book","tags":"papeterie"},
|
||||
{"itemId":"cahier","name":"Cahier","emoji":"📓","aliases":"notebook","tags":"papeterie"},
|
||||
{"itemId":"stylo","name":"Stylo","emoji":"🖊️","aliases":"pen","tags":"papeterie"},
|
||||
{"itemId":"crayon","name":"Crayon","emoji":"✏️","aliases":"pencil","tags":"papeterie"},
|
||||
{"itemId":"surligneur","name":"Surligneur","emoji":"🖍️","tags":"papeterie"},
|
||||
{"itemId":"calculatrice","name":"Calculatrice","emoji":"🔢","tags":"papeterie"}
|
||||
]}
|
||||
]},
|
||||
{"domainId":"cleaning","name":"Entretien & Ménage","emoji":"🧹","color":"#00BCD4","sortOrder":9,"categories":[
|
||||
{"categoryId":"cleaning_products","name":"Produits Ménagers","emoji":"🧴","color":"#26C6DA","sortOrder":0,"items":[
|
||||
{"itemId":"nettoyant_tout_usage","name":"Nettoyant tout usage","emoji":"🧴","tags":"menage"},
|
||||
{"itemId":"nettoyant_vitres","name":"Nettoyant à vitres","emoji":"🧴","aliases":"windex","tags":"menage"},
|
||||
{"itemId":"javellisant","name":"Javellisant","emoji":"🧴","aliases":"javel","tags":"menage"},
|
||||
{"itemId":"desinfectant","name":"Désinfectant","emoji":"🧴","aliases":"lysol","tags":"menage"},
|
||||
{"itemId":"nettoyant_plancher","name":"Nettoyant plancher","emoji":"🧴","tags":"menage"},
|
||||
{"itemId":"deboucheur","name":"Déboucheur","emoji":"🚿","tags":"menage"}
|
||||
]},
|
||||
{"categoryId":"laundry","name":"Lessive","emoji":"👕","color":"#80DEEA","sortOrder":1,"items":[
|
||||
{"itemId":"detergent","name":"Détergent à lessive","emoji":"🧺","aliases":"laundry detergent","tags":"lessive"},
|
||||
{"itemId":"assouplissant","name":"Assouplissant","emoji":"🧺","aliases":"fabric softener","tags":"lessive"},
|
||||
{"itemId":"feuilles_secheuse","name":"Feuilles pour sécheuse","emoji":"🧺","tags":"lessive"},
|
||||
{"itemId":"detachant","name":"Détachant","emoji":"🧴","tags":"lessive"}
|
||||
]},
|
||||
{"categoryId":"disposables","name":"Articles Jetables","emoji":"🧻","color":"#4DD0E1","sortOrder":2,"items":[
|
||||
{"itemId":"papier_toilette","name":"Papier toilette","emoji":"🧻","aliases":"toilet paper","tags":"jetable"},
|
||||
{"itemId":"essuie_tout","name":"Essuie-tout","emoji":"🧻","aliases":"paper towel","tags":"jetable"},
|
||||
{"itemId":"sacs_poubelle","name":"Sacs poubelle","emoji":"🗑️","aliases":"trash bags","tags":"jetable"},
|
||||
{"itemId":"sacs_ziploc","name":"Sacs Ziploc","emoji":"🗑️","tags":"jetable"},
|
||||
{"itemId":"papier_aluminium","name":"Papier aluminium","emoji":"📦","aliases":"aluminum foil","tags":"jetable"},
|
||||
{"itemId":"pellicule_plastique","name":"Pellicule plastique","emoji":"📦","aliases":"plastic wrap","tags":"jetable"},
|
||||
{"itemId":"papier_parchemin","name":"Papier parchemin","emoji":"📦","tags":"jetable"}
|
||||
]},
|
||||
{"categoryId":"cleaning_tools","name":"Outils de Ménage","emoji":"🧹","color":"#26C6DA","sortOrder":3,"items":[
|
||||
{"itemId":"balai","name":"Balai","emoji":"🧹","aliases":"broom","tags":"menage"},
|
||||
{"itemId":"vadrouille","name":"Vadrouille","emoji":"🧹","aliases":"mop|moppe","tags":"menage"},
|
||||
{"itemId":"seau","name":"Seau","emoji":"🪣","aliases":"bucket","tags":"menage"},
|
||||
{"itemId":"eponges","name":"Éponges","emoji":"🧽","aliases":"sponges","tags":"menage"},
|
||||
{"itemId":"chiffons","name":"Chiffons","emoji":"🧽","aliases":"rags","tags":"menage"},
|
||||
{"itemId":"gants_menage","name":"Gants de ménage","emoji":"🧤","tags":"menage"}
|
||||
]}
|
||||
]},
|
||||
{"domainId":"gifts_events","name":"Fêtes & Cadeaux","emoji":"🎁","color":"#E91E63","sortOrder":10,"categories":[
|
||||
{"categoryId":"party","name":"Articles de Fête","emoji":"🎉","color":"#F06292","sortOrder":0,"items":[
|
||||
{"itemId":"ballons","name":"Ballons","emoji":"🎈","aliases":"balloons","tags":"fete"},
|
||||
{"itemId":"banniere","name":"Bannière","emoji":"🎊","tags":"fete"},
|
||||
{"itemId":"bougies_anniversaire","name":"Bougies d'anniversaire","emoji":"🕯️","tags":"fete"},
|
||||
{"itemId":"chapeaux_fete","name":"Chapeaux de fête","emoji":"🎉","tags":"fete"},
|
||||
{"itemId":"nappes_fete","name":"Nappes de fête","emoji":"🎉","tags":"fete"}
|
||||
]},
|
||||
{"categoryId":"gift_wrap","name":"Emballage Cadeaux","emoji":"🎀","color":"#EC407A","sortOrder":1,"items":[
|
||||
{"itemId":"papier_emballage","name":"Papier d'emballage","emoji":"🎁","tags":"cadeau"},
|
||||
{"itemId":"sac_cadeau","name":"Sac cadeau","emoji":"🎁","tags":"cadeau"},
|
||||
{"itemId":"ruban","name":"Ruban","emoji":"🎀","tags":"cadeau"},
|
||||
{"itemId":"carte_voeux","name":"Carte de vœux","emoji":"💌","aliases":"greeting card","tags":"cadeau"}
|
||||
]},
|
||||
{"categoryId":"seasonal","name":"Articles Saisonniers","emoji":"🎄","color":"#EF5350","sortOrder":2,"items":[
|
||||
{"itemId":"sapin_noel","name":"Sapin de Noël","emoji":"🎄","tags":"saisonnier"},
|
||||
{"itemId":"boules_noel","name":"Boules de Noël","emoji":"🎄","tags":"saisonnier"},
|
||||
{"itemId":"lumieres_noel","name":"Lumières de Noël","emoji":"💡","tags":"saisonnier"},
|
||||
{"itemId":"sel_deglacer","name":"Sel à déglacer","emoji":"❄️","tags":"saisonnier"},
|
||||
{"itemId":"pelle_neige","name":"Pelle à neige","emoji":"🪚","aliases":"snow shovel","tags":"saisonnier"}
|
||||
]}
|
||||
]},
|
||||
{"domainId":"office_school","name":"Bureau & École","emoji":"📎","color":"#3F51B5","sortOrder":11,"categories":[
|
||||
{"categoryId":"school_supplies","name":"Fournitures Scolaires","emoji":"🎒","color":"#5C6BC0","sortOrder":0,"items":[
|
||||
{"itemId":"sac_ecole","name":"Sac d'école","emoji":"🎒","aliases":"backpack","tags":"ecole"},
|
||||
{"itemId":"etui_crayons","name":"Étui à crayons","emoji":"✏️","tags":"ecole"},
|
||||
{"itemId":"cartable","name":"Cartable","emoji":"📁","tags":"ecole"},
|
||||
{"itemId":"feuilles_mobiles","name":"Feuilles mobiles","emoji":"📄","tags":"ecole"},
|
||||
{"itemId":"crayons_couleur","name":"Crayons de couleur","emoji":"🖍️","tags":"ecole"},
|
||||
{"itemId":"baton_colle","name":"Bâton de colle","emoji":"🧴","aliases":"glue stick","tags":"ecole"},
|
||||
{"itemId":"regle","name":"Règle","emoji":"📏","aliases":"ruler","tags":"ecole"}
|
||||
]},
|
||||
{"categoryId":"office","name":"Fournitures Bureau","emoji":"🖨️","color":"#7986CB","sortOrder":1,"items":[
|
||||
{"itemId":"papier_imprimante","name":"Papier d'imprimante","emoji":"📄","tags":"bureau"},
|
||||
{"itemId":"enveloppes","name":"Enveloppes","emoji":"✉️","tags":"bureau"},
|
||||
{"itemId":"agrafeuse","name":"Agrafeuse","emoji":"📎","aliases":"stapler","tags":"bureau"},
|
||||
{"itemId":"trombones","name":"Trombones","emoji":"📎","aliases":"paper clips","tags":"bureau"},
|
||||
{"itemId":"post_it","name":"Post-it","emoji":"📝","tags":"bureau"},
|
||||
{"itemId":"agenda","name":"Agenda","emoji":"📅","aliases":"planner","tags":"bureau"}
|
||||
]}
|
||||
]}
|
||||
]}
|
||||
@ -1,15 +1,30 @@
|
||||
package com.safebite.app
|
||||
|
||||
import android.app.Application
|
||||
import com.safebite.app.data.local.seed.CatalogSeedManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class SafeBiteApplication : Application() {
|
||||
|
||||
@Inject lateinit var catalogSeedManager: CatalogSeedManager
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
appScope.launch {
|
||||
runCatching { catalogSeedManager.seedIfNeeded() }
|
||||
.onFailure { Timber.e(it, "Catalog seeding failed at startup") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,12 +3,17 @@ package com.safebite.app.data.local.database
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.safebite.app.data.local.database.dao.CatalogDao
|
||||
import com.safebite.app.data.local.database.dao.ProductCacheDao
|
||||
import com.safebite.app.data.local.database.dao.ScanHistoryDao
|
||||
import com.safebite.app.data.local.database.dao.ShoppingListDao
|
||||
import com.safebite.app.data.local.database.dao.UserProfileDao
|
||||
import com.safebite.app.data.local.database.entity.CatalogItemEntity
|
||||
import com.safebite.app.data.local.database.entity.CategoryEntity
|
||||
import com.safebite.app.data.local.database.entity.ItemCategoryCrossRef
|
||||
import com.safebite.app.data.local.database.entity.ProductCacheEntity
|
||||
import com.safebite.app.data.local.database.entity.ScanHistoryEntity
|
||||
import com.safebite.app.data.local.database.entity.ShoppingDomainEntity
|
||||
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.ShoppingListMemberEntity
|
||||
@ -21,9 +26,13 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity
|
||||
ScanHistoryEntity::class,
|
||||
ShoppingListEntity::class,
|
||||
ShoppingListItemEntity::class,
|
||||
ShoppingListMemberEntity::class
|
||||
ShoppingListMemberEntity::class,
|
||||
ShoppingDomainEntity::class,
|
||||
CategoryEntity::class,
|
||||
CatalogItemEntity::class,
|
||||
ItemCategoryCrossRef::class
|
||||
],
|
||||
version = 7,
|
||||
version = 8,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@ -32,6 +41,7 @@ abstract class SafeBiteDatabase : RoomDatabase() {
|
||||
abstract fun productCacheDao(): ProductCacheDao
|
||||
abstract fun scanHistoryDao(): ScanHistoryDao
|
||||
abstract fun shoppingListDao(): ShoppingListDao
|
||||
abstract fun catalogDao(): CatalogDao
|
||||
|
||||
companion object {
|
||||
const val NAME = "safebite.db"
|
||||
|
||||
@ -0,0 +1,114 @@
|
||||
package com.safebite.app.data.local.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.safebite.app.data.local.database.entity.CatalogItemEntity
|
||||
import com.safebite.app.data.local.database.entity.CategoryEntity
|
||||
import com.safebite.app.data.local.database.entity.ItemCategoryCrossRef
|
||||
import com.safebite.app.data.local.database.entity.ShoppingDomainEntity
|
||||
import com.safebite.app.data.local.database.relation.CategoryWithItems
|
||||
import com.safebite.app.data.local.database.relation.DomainWithCategories
|
||||
import com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface CatalogDao {
|
||||
|
||||
// ── Domaines ──────────────────────────────────────────────────────────────
|
||||
@Query("SELECT * FROM shopping_domains WHERE isActive = 1 ORDER BY sortOrder")
|
||||
fun getAllDomains(): Flow<List<ShoppingDomainEntity>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM shopping_domains WHERE isActive = 1 ORDER BY sortOrder")
|
||||
fun getDomainsWithCategories(): Flow<List<DomainWithCategories>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM shopping_domains WHERE isActive = 1 ORDER BY sortOrder")
|
||||
fun getDomainsWithCategoriesAndItems(): Flow<List<DomainWithCategoriesAndItems>>
|
||||
|
||||
@Query("SELECT * FROM shopping_domains WHERE domainId = :domainId")
|
||||
suspend fun getDomainById(domainId: String): ShoppingDomainEntity?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM shopping_domains")
|
||||
suspend fun getDomainCount(): Int
|
||||
|
||||
// ── Catégories ────────────────────────────────────────────────────────────
|
||||
@Query("SELECT * FROM categories WHERE domainId = :domainId AND isActive = 1 ORDER BY sortOrder")
|
||||
fun getCategoriesForDomain(domainId: String): Flow<List<CategoryEntity>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM categories WHERE categoryId = :categoryId")
|
||||
fun getCategoryWithItems(categoryId: String): Flow<CategoryWithItems?>
|
||||
|
||||
@Query("SELECT * FROM categories WHERE isActive = 1 ORDER BY sortOrder")
|
||||
fun getAllCategories(): Flow<List<CategoryEntity>>
|
||||
|
||||
@Query("SELECT * FROM categories WHERE isActive = 1 ORDER BY sortOrder")
|
||||
suspend fun getAllCategoriesNow(): List<CategoryEntity>
|
||||
|
||||
@Query("SELECT * FROM categories WHERE categoryId = :categoryId")
|
||||
suspend fun getCategoryById(categoryId: String): CategoryEntity?
|
||||
|
||||
@Query("SELECT * FROM categories WHERE name = :name COLLATE NOCASE LIMIT 1")
|
||||
suspend fun getCategoryByName(name: String): CategoryEntity?
|
||||
|
||||
// ── Articles ──────────────────────────────────────────────────────────────
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM catalog_items
|
||||
WHERE name LIKE '%' || :query || '%'
|
||||
OR aliases LIKE '%' || :query || '%'
|
||||
OR tags LIKE '%' || :query || '%'
|
||||
ORDER BY
|
||||
CASE WHEN name LIKE :query || '%' THEN 0 ELSE 1 END,
|
||||
popularity DESC,
|
||||
name ASC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
fun searchItems(query: String, limit: Int = 20): Flow<List<CatalogItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM catalog_items WHERE primaryCategoryId = :categoryId ORDER BY sortOrder, name")
|
||||
fun getItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM catalog_items WHERE primaryCategoryId = :categoryId ORDER BY sortOrder, name")
|
||||
suspend fun getItemsForCategoryNow(categoryId: String): List<CatalogItemEntity>
|
||||
|
||||
@Query("SELECT * FROM catalog_items ORDER BY popularity DESC, name ASC LIMIT :limit")
|
||||
fun getPopularItems(limit: Int = 15): Flow<List<CatalogItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM catalog_items WHERE barcode = :barcode LIMIT 1")
|
||||
suspend fun getItemByBarcode(barcode: String): CatalogItemEntity?
|
||||
|
||||
@Query("SELECT * FROM catalog_items WHERE itemId = :itemId")
|
||||
suspend fun getItemById(itemId: String): CatalogItemEntity?
|
||||
|
||||
@Query("SELECT * FROM catalog_items WHERE name = :name COLLATE NOCASE LIMIT 1")
|
||||
suspend fun getItemByName(name: String): CatalogItemEntity?
|
||||
|
||||
@Query("UPDATE catalog_items SET popularity = popularity + 1 WHERE itemId = :itemId")
|
||||
suspend fun incrementPopularity(itemId: String)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM catalog_items")
|
||||
suspend fun getItemCount(): Int
|
||||
|
||||
// ── Insertions seed ───────────────────────────────────────────────────────
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertDomains(domains: List<ShoppingDomainEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertCategories(categories: List<CategoryEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertItems(items: List<CatalogItemEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertCrossRefs(refs: List<ItemCategoryCrossRef>)
|
||||
|
||||
// ── Articles utilisateur ──────────────────────────────────────────────────
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertItem(item: CatalogItemEntity)
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
package com.safebite.app.data.local.database.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Catalogue produit Room — hiérarchie Domaine → Catégorie → Article.
|
||||
*
|
||||
* Pré-peuplé depuis [assets/catalog_seed.json] au premier démarrage via
|
||||
* [CatalogSeedManager]. Les écrans existants continuent de référencer la
|
||||
* catégorie par son **nom** (compat) tandis que les nouveaux écrans utilisent
|
||||
* les `*Id` typés.
|
||||
*/
|
||||
|
||||
@Entity(tableName = "shopping_domains")
|
||||
data class ShoppingDomainEntity(
|
||||
@PrimaryKey val domainId: String,
|
||||
val name: String,
|
||||
val emoji: String,
|
||||
val iconResName: String? = null,
|
||||
val color: String? = null,
|
||||
val sortOrder: Int,
|
||||
val isActive: Boolean = true
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "categories",
|
||||
foreignKeys = [ForeignKey(
|
||||
entity = ShoppingDomainEntity::class,
|
||||
parentColumns = ["domainId"],
|
||||
childColumns = ["domainId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)],
|
||||
indices = [Index("domainId"), Index("name")]
|
||||
)
|
||||
data class CategoryEntity(
|
||||
@PrimaryKey val categoryId: String,
|
||||
val domainId: String,
|
||||
val name: String,
|
||||
val emoji: String,
|
||||
val iconResName: String? = null,
|
||||
val color: String? = null,
|
||||
val sortOrder: Int,
|
||||
val isActive: Boolean = true
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "catalog_items",
|
||||
foreignKeys = [ForeignKey(
|
||||
entity = CategoryEntity::class,
|
||||
parentColumns = ["categoryId"],
|
||||
childColumns = ["primaryCategoryId"],
|
||||
onDelete = ForeignKey.SET_NULL
|
||||
)],
|
||||
indices = [
|
||||
Index("primaryCategoryId"),
|
||||
Index("name"),
|
||||
Index("barcode")
|
||||
]
|
||||
)
|
||||
data class CatalogItemEntity(
|
||||
@PrimaryKey val itemId: String,
|
||||
val name: String,
|
||||
val primaryCategoryId: String?,
|
||||
val emoji: String,
|
||||
val iconUrl: String? = null,
|
||||
val barcode: String? = null,
|
||||
val aliases: String = "",
|
||||
val tags: String = "",
|
||||
val isUserCreated: Boolean = false,
|
||||
val popularity: Int = 0,
|
||||
val sortOrder: Int = 0
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "item_category_cross_ref",
|
||||
primaryKeys = ["itemId", "categoryId"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = CatalogItemEntity::class,
|
||||
parentColumns = ["itemId"],
|
||||
childColumns = ["itemId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
ForeignKey(
|
||||
entity = CategoryEntity::class,
|
||||
parentColumns = ["categoryId"],
|
||||
childColumns = ["categoryId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index("categoryId")]
|
||||
)
|
||||
data class ItemCategoryCrossRef(
|
||||
val itemId: String,
|
||||
val categoryId: String
|
||||
)
|
||||
@ -0,0 +1,80 @@
|
||||
package com.safebite.app.data.local.database.migration
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
/**
|
||||
* Migration additive : crée les 4 tables du nouveau catalogue
|
||||
* (domaines, catégories, articles, cross-ref) + leurs index. Aucune
|
||||
* donnée existante n'est touchée. Le seed JSON est appliqué après ouverture.
|
||||
*/
|
||||
val MIGRATION_7_8: Migration = object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS shopping_domains (
|
||||
domainId TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
iconResName TEXT,
|
||||
color TEXT,
|
||||
sortOrder INTEGER NOT NULL,
|
||||
isActive INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
categoryId TEXT NOT NULL PRIMARY KEY,
|
||||
domainId TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
iconResName TEXT,
|
||||
color TEXT,
|
||||
sortOrder INTEGER NOT NULL,
|
||||
isActive INTEGER NOT NULL,
|
||||
FOREIGN KEY(domainId) REFERENCES shopping_domains(domainId) ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_domainId ON categories(domainId)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_name ON categories(name)")
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS catalog_items (
|
||||
itemId TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
primaryCategoryId TEXT,
|
||||
emoji TEXT NOT NULL,
|
||||
iconUrl TEXT,
|
||||
barcode TEXT,
|
||||
aliases TEXT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
isUserCreated INTEGER NOT NULL,
|
||||
popularity INTEGER NOT NULL,
|
||||
sortOrder INTEGER NOT NULL,
|
||||
FOREIGN KEY(primaryCategoryId) REFERENCES categories(categoryId) ON DELETE SET NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_primaryCategoryId ON catalog_items(primaryCategoryId)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_name ON catalog_items(name)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_barcode ON catalog_items(barcode)")
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS item_category_cross_ref (
|
||||
itemId TEXT NOT NULL,
|
||||
categoryId TEXT NOT NULL,
|
||||
PRIMARY KEY(itemId, categoryId),
|
||||
FOREIGN KEY(itemId) REFERENCES catalog_items(itemId) ON DELETE CASCADE,
|
||||
FOREIGN KEY(categoryId) REFERENCES categories(categoryId) ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_item_category_cross_ref_categoryId ON item_category_cross_ref(categoryId)")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.safebite.app.data.local.database.relation
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
import com.safebite.app.data.local.database.entity.CatalogItemEntity
|
||||
import com.safebite.app.data.local.database.entity.CategoryEntity
|
||||
import com.safebite.app.data.local.database.entity.ItemCategoryCrossRef
|
||||
import com.safebite.app.data.local.database.entity.ShoppingDomainEntity
|
||||
|
||||
data class DomainWithCategories(
|
||||
@Embedded val domain: ShoppingDomainEntity,
|
||||
@Relation(parentColumn = "domainId", entityColumn = "domainId")
|
||||
val categories: List<CategoryEntity>
|
||||
)
|
||||
|
||||
data class CategoryWithItems(
|
||||
@Embedded val category: CategoryEntity,
|
||||
@Relation(
|
||||
parentColumn = "categoryId",
|
||||
entityColumn = "itemId",
|
||||
associateBy = Junction(
|
||||
value = ItemCategoryCrossRef::class,
|
||||
parentColumn = "categoryId",
|
||||
entityColumn = "itemId"
|
||||
)
|
||||
)
|
||||
val items: List<CatalogItemEntity>
|
||||
)
|
||||
|
||||
data class DomainWithCategoriesAndItems(
|
||||
@Embedded val domain: ShoppingDomainEntity,
|
||||
@Relation(
|
||||
entity = CategoryEntity::class,
|
||||
parentColumn = "domainId",
|
||||
entityColumn = "domainId"
|
||||
)
|
||||
val categoriesWithItems: List<CategoryWithItems>
|
||||
)
|
||||
@ -0,0 +1,106 @@
|
||||
package com.safebite.app.data.local.seed
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.withTransaction
|
||||
import com.safebite.app.data.local.database.SafeBiteDatabase
|
||||
import com.safebite.app.data.local.database.dao.CatalogDao
|
||||
import com.safebite.app.data.local.database.entity.CatalogItemEntity
|
||||
import com.safebite.app.data.local.database.entity.CategoryEntity
|
||||
import com.safebite.app.data.local.database.entity.ItemCategoryCrossRef
|
||||
import com.safebite.app.data.local.database.entity.ShoppingDomainEntity
|
||||
import android.util.Log
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Importe le fichier `assets/catalog_seed.json` dans la base Room au premier
|
||||
* lancement (ou après une migration qui a créé les tables vides).
|
||||
*/
|
||||
@Singleton
|
||||
class CatalogSeedManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val database: SafeBiteDatabase,
|
||||
private val catalogDao: CatalogDao,
|
||||
private val moshi: Moshi
|
||||
) {
|
||||
suspend fun seedIfNeeded() = withContext(Dispatchers.IO) {
|
||||
val existing = catalogDao.getDomainCount()
|
||||
Log.i(TAG, "Catalog seed check: existing domains = $existing")
|
||||
if (existing > 0) return@withContext
|
||||
runCatching { seedFromJson() }
|
||||
.onSuccess { Log.i(TAG, "Catalog seeded successfully") }
|
||||
.onFailure {
|
||||
Log.e(TAG, "Catalog seed failed: ${it.message}", it)
|
||||
Timber.e(it, "Catalog seed failed")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun seedFromJson() {
|
||||
val json = context.assets.open(SEED_ASSET).bufferedReader().use { it.readText() }
|
||||
val adapter = moshi.adapter(CatalogSeed::class.java)
|
||||
val seed = adapter.fromJson(json) ?: error("catalog_seed.json invalide")
|
||||
|
||||
database.withTransaction {
|
||||
seed.domains.forEach { domainSeed ->
|
||||
catalogDao.insertDomains(
|
||||
listOf(
|
||||
ShoppingDomainEntity(
|
||||
domainId = domainSeed.domainId,
|
||||
name = domainSeed.name,
|
||||
emoji = domainSeed.emoji,
|
||||
color = domainSeed.color,
|
||||
sortOrder = domainSeed.sortOrder,
|
||||
isActive = true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
domainSeed.categories.forEach { catSeed ->
|
||||
catalogDao.insertCategories(
|
||||
listOf(
|
||||
CategoryEntity(
|
||||
categoryId = catSeed.categoryId,
|
||||
domainId = domainSeed.domainId,
|
||||
name = catSeed.name,
|
||||
emoji = catSeed.emoji,
|
||||
color = catSeed.color,
|
||||
sortOrder = catSeed.sortOrder,
|
||||
isActive = true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val items = catSeed.items.mapIndexed { index, itemSeed ->
|
||||
CatalogItemEntity(
|
||||
itemId = itemSeed.itemId,
|
||||
name = itemSeed.name,
|
||||
primaryCategoryId = catSeed.categoryId,
|
||||
emoji = itemSeed.emoji,
|
||||
aliases = itemSeed.aliases.orEmpty(),
|
||||
tags = itemSeed.tags.orEmpty(),
|
||||
barcode = itemSeed.barcode,
|
||||
sortOrder = index
|
||||
)
|
||||
}
|
||||
if (items.isNotEmpty()) {
|
||||
catalogDao.insertItems(items)
|
||||
catalogDao.insertCrossRefs(
|
||||
items.map { ItemCategoryCrossRef(it.itemId, catSeed.categoryId) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.i("Catalog seeded: %d domains", seed.domains.size)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SEED_ASSET = "catalog_seed.json"
|
||||
private const val TAG = "CatalogSeedManager"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package com.safebite.app.data.local.seed
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/**
|
||||
* Modèles de désérialisation du fichier `assets/catalog_seed.json`.
|
||||
* Adapter Moshi généré par codegen.
|
||||
*/
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CatalogSeed(
|
||||
val version: Int,
|
||||
val domains: List<DomainSeed>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class DomainSeed(
|
||||
val domainId: String,
|
||||
val name: String,
|
||||
val emoji: String,
|
||||
val color: String? = null,
|
||||
val sortOrder: Int,
|
||||
val categories: List<CategorySeed>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CategorySeed(
|
||||
val categoryId: String,
|
||||
val name: String,
|
||||
val emoji: String,
|
||||
val color: String? = null,
|
||||
val sortOrder: Int,
|
||||
val items: List<ItemSeed>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ItemSeed(
|
||||
val itemId: String,
|
||||
val name: String,
|
||||
val emoji: String,
|
||||
val aliases: String? = null,
|
||||
val tags: String? = null,
|
||||
val barcode: String? = null
|
||||
)
|
||||
@ -0,0 +1,82 @@
|
||||
package com.safebite.app.data.repository
|
||||
|
||||
import com.safebite.app.data.local.database.dao.CatalogDao
|
||||
import com.safebite.app.data.local.database.entity.CatalogItemEntity
|
||||
import com.safebite.app.data.local.database.entity.CategoryEntity
|
||||
import com.safebite.app.data.local.database.entity.ItemCategoryCrossRef
|
||||
import com.safebite.app.data.local.database.entity.ShoppingDomainEntity
|
||||
import com.safebite.app.data.local.database.relation.CategoryWithItems
|
||||
import com.safebite.app.data.local.database.relation.DomainWithCategories
|
||||
import com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Façade applicative au-dessus de [CatalogDao]. Fournit les flux nécessaires
|
||||
* aux écrans Catalogue, Catégories, Articles et Recherche.
|
||||
*/
|
||||
@Singleton
|
||||
class CatalogRepository @Inject constructor(
|
||||
private val dao: CatalogDao
|
||||
) {
|
||||
|
||||
fun observeDomains(): Flow<List<ShoppingDomainEntity>> = dao.getAllDomains()
|
||||
|
||||
fun observeDomainsWithCategories(): Flow<List<DomainWithCategories>> =
|
||||
dao.getDomainsWithCategories()
|
||||
|
||||
fun observeDomainsWithCategoriesAndItems(): Flow<List<DomainWithCategoriesAndItems>> =
|
||||
dao.getDomainsWithCategoriesAndItems()
|
||||
|
||||
fun observeCategoriesForDomain(domainId: String): Flow<List<CategoryEntity>> =
|
||||
dao.getCategoriesForDomain(domainId)
|
||||
|
||||
fun observeCategoryWithItems(categoryId: String): Flow<CategoryWithItems?> =
|
||||
dao.getCategoryWithItems(categoryId)
|
||||
|
||||
fun observeItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>> =
|
||||
dao.getItemsForCategory(categoryId)
|
||||
|
||||
fun observePopularItems(limit: Int = 15): Flow<List<CatalogItemEntity>> =
|
||||
dao.getPopularItems(limit)
|
||||
|
||||
fun search(query: String, limit: Int = 20): Flow<List<CatalogItemEntity>> =
|
||||
dao.searchItems(query.trim(), limit)
|
||||
|
||||
suspend fun getDomain(domainId: String): ShoppingDomainEntity? = dao.getDomainById(domainId)
|
||||
suspend fun getCategory(categoryId: String): CategoryEntity? = dao.getCategoryById(categoryId)
|
||||
suspend fun getItem(itemId: String): CatalogItemEntity? = dao.getItemById(itemId)
|
||||
suspend fun getItemByBarcode(barcode: String): CatalogItemEntity? = dao.getItemByBarcode(barcode)
|
||||
suspend fun incrementPopularity(itemId: String) = dao.incrementPopularity(itemId)
|
||||
|
||||
/**
|
||||
* Crée un article personnalisé (généré par l'utilisateur), persisté avec
|
||||
* `isUserCreated = true` afin de pouvoir filtrer / exporter ces ajouts.
|
||||
*/
|
||||
suspend fun createUserItem(
|
||||
name: String,
|
||||
emoji: String,
|
||||
primaryCategoryId: String?,
|
||||
aliases: String = "",
|
||||
tags: String = ""
|
||||
): CatalogItemEntity {
|
||||
val item = CatalogItemEntity(
|
||||
itemId = "user_${UUID.randomUUID()}",
|
||||
name = name,
|
||||
primaryCategoryId = primaryCategoryId,
|
||||
emoji = emoji,
|
||||
aliases = aliases,
|
||||
tags = tags,
|
||||
isUserCreated = true,
|
||||
popularity = 0,
|
||||
sortOrder = 0
|
||||
)
|
||||
dao.insertItem(item)
|
||||
if (primaryCategoryId != null) {
|
||||
dao.insertCrossRefs(listOf(ItemCategoryCrossRef(item.itemId, primaryCategoryId)))
|
||||
}
|
||||
return item
|
||||
}
|
||||
}
|
||||
@ -3,10 +3,12 @@ package com.safebite.app.di
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.safebite.app.data.local.database.SafeBiteDatabase
|
||||
import com.safebite.app.data.local.database.dao.CatalogDao
|
||||
import com.safebite.app.data.local.database.dao.ProductCacheDao
|
||||
import com.safebite.app.data.local.database.dao.ScanHistoryDao
|
||||
import com.safebite.app.data.local.database.dao.ShoppingListDao
|
||||
import com.safebite.app.data.local.database.dao.UserProfileDao
|
||||
import com.safebite.app.data.local.database.migration.MIGRATION_7_8
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@ -22,6 +24,7 @@ object DatabaseModule {
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): SafeBiteDatabase =
|
||||
Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME)
|
||||
.addMigrations(MIGRATION_7_8)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
@ -29,4 +32,5 @@ object DatabaseModule {
|
||||
@Provides fun provideProductCacheDao(db: SafeBiteDatabase): ProductCacheDao = db.productCacheDao()
|
||||
@Provides fun provideScanHistoryDao(db: SafeBiteDatabase): ScanHistoryDao = db.scanHistoryDao()
|
||||
@Provides fun provideShoppingListDao(db: SafeBiteDatabase): ShoppingListDao = db.shoppingListDao()
|
||||
@Provides fun provideCatalogDao(db: SafeBiteDatabase): CatalogDao = db.catalogDao()
|
||||
}
|
||||
|
||||
@ -12,6 +12,10 @@ import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.safebite.app.presentation.screen.catalog.CatalogScreen
|
||||
import com.safebite.app.presentation.screen.catalog.CategoryItemsScreen
|
||||
import com.safebite.app.presentation.screen.catalog.CatalogSearchScreen
|
||||
import com.safebite.app.presentation.screen.catalog.DomainCategoriesScreen
|
||||
import com.safebite.app.presentation.screen.product.ProductDetailScreen
|
||||
import com.safebite.app.presentation.screen.tracking.TrackingScreen
|
||||
import com.safebite.app.presentation.screen.lists.ListDetailScreen
|
||||
@ -210,7 +214,68 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
||||
listName = listName,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenScanner = { navController.navigate(Screen.Scanner.route) },
|
||||
onOpenProduct = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }
|
||||
onOpenProduct = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
|
||||
onOpenCatalog = { id -> navController.navigate(Screen.Catalog.build(id)) }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Catalogue (refonte) ──
|
||||
composable(
|
||||
route = Screen.Catalog.route,
|
||||
arguments = listOf(navArgument("listId") { type = NavType.LongType })
|
||||
) { entry ->
|
||||
val listId = entry.arguments?.getLong("listId") ?: 0L
|
||||
CatalogScreen(
|
||||
listId = listId,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenDomain = { domainId ->
|
||||
navController.navigate(Screen.CatalogDomain.build(listId, domainId))
|
||||
},
|
||||
onOpenSearch = {
|
||||
navController.navigate(Screen.CatalogSearch.build(listId))
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.CatalogDomain.route,
|
||||
arguments = listOf(
|
||||
navArgument("listId") { type = NavType.LongType },
|
||||
navArgument("domainId") { type = NavType.StringType }
|
||||
)
|
||||
) { entry ->
|
||||
val listId = entry.arguments?.getLong("listId") ?: 0L
|
||||
val domainId = entry.arguments?.getString("domainId").orEmpty()
|
||||
DomainCategoriesScreen(
|
||||
domainId = domainId,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenCategory = { categoryId ->
|
||||
navController.navigate(Screen.CatalogCategory.build(listId, categoryId))
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.CatalogCategory.route,
|
||||
arguments = listOf(
|
||||
navArgument("listId") { type = NavType.LongType },
|
||||
navArgument("categoryId") { type = NavType.StringType }
|
||||
)
|
||||
) { entry ->
|
||||
val listId = entry.arguments?.getLong("listId") ?: 0L
|
||||
val categoryId = entry.arguments?.getString("categoryId").orEmpty()
|
||||
CategoryItemsScreen(
|
||||
categoryId = categoryId,
|
||||
listId = listId,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.CatalogSearch.route,
|
||||
arguments = listOf(navArgument("listId") { type = NavType.LongType })
|
||||
) { entry ->
|
||||
val listId = entry.arguments?.getLong("listId") ?: 0L
|
||||
CatalogSearchScreen(
|
||||
listId = listId,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -75,6 +75,20 @@ sealed class Screen(val route: String) {
|
||||
data object ListMembers : Screen("list/members/{id}") {
|
||||
fun build(id: Long) = "list/members/$id"
|
||||
}
|
||||
|
||||
// ── Catalogue (refonte) ──
|
||||
data object Catalog : Screen("catalog/{listId}") {
|
||||
fun build(listId: Long) = "catalog/$listId"
|
||||
}
|
||||
data object CatalogDomain : Screen("catalog/{listId}/domain/{domainId}") {
|
||||
fun build(listId: Long, domainId: String) = "catalog/$listId/domain/$domainId"
|
||||
}
|
||||
data object CatalogCategory : Screen("catalog/{listId}/category/{categoryId}") {
|
||||
fun build(listId: Long, categoryId: String) = "catalog/$listId/category/$categoryId"
|
||||
}
|
||||
data object CatalogSearch : Screen("catalog/{listId}/search") {
|
||||
fun build(listId: Long) = "catalog/$listId/search"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,404 @@
|
||||
package com.safebite.app.presentation.screen.catalog
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.Clear
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.safebite.app.data.local.database.entity.CatalogItemEntity
|
||||
import com.safebite.app.data.local.database.entity.CategoryEntity
|
||||
import com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private fun parseColor(hex: String?): Color? = runCatching {
|
||||
hex?.takeIf { it.startsWith("#") }?.let { Color(android.graphics.Color.parseColor(it)) }
|
||||
}.getOrNull()
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CatalogScreen(
|
||||
listId: Long,
|
||||
onBack: () -> Unit,
|
||||
onOpenDomain: (String) -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
viewModel: CatalogViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(listId) { viewModel.setActiveList(listId) }
|
||||
val domains by viewModel.domains.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Catalogue") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(Icons.Filled.Search, contentDescription = "Rechercher")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
if (domains.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(domains, key = { it.domain.domainId }) { domain ->
|
||||
DomainCard(
|
||||
domain = domain,
|
||||
onClick = { onOpenDomain(domain.domain.domainId) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DomainCard(
|
||||
domain: DomainWithCategoriesAndItems,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val color = parseColor(domain.domain.color) ?: MaterialTheme.colorScheme.primaryContainer
|
||||
val itemCount = domain.categoriesWithItems.sumOf { it.items.size }
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.18f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = domain.domain.emoji,
|
||||
style = MaterialTheme.typography.displayMedium
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = domain.domain.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${domain.categoriesWithItems.size} catégories • $itemCount articles",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DomainCategoriesScreen(
|
||||
domainId: String,
|
||||
onBack: () -> Unit,
|
||||
onOpenCategory: (String) -> Unit,
|
||||
viewModel: CatalogViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(domainId) { viewModel.selectDomain(domainId) }
|
||||
val categories by viewModel.categoriesForSelectedDomain.collectAsStateWithLifecycle()
|
||||
val domains by viewModel.domains.collectAsStateWithLifecycle()
|
||||
val domain = domains.firstOrNull { it.domain.domainId == domainId }?.domain
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(domain?.let { "${it.emoji} ${it.name}" } ?: "Catégories") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(categories, key = { it.categoryId }) { cat ->
|
||||
CategoryRow(category = cat, onClick = { onOpenCategory(cat.categoryId) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryRow(category: CategoryEntity, onClick: () -> Unit) {
|
||||
val color = parseColor(category.color) ?: MaterialTheme.colorScheme.surfaceVariant
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.20f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color.copy(alpha = 0.4f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = category.emoji, style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = category.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CategoryItemsScreen(
|
||||
categoryId: String,
|
||||
listId: Long,
|
||||
onBack: () -> Unit,
|
||||
viewModel: CatalogViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(categoryId, listId) {
|
||||
viewModel.setActiveList(listId)
|
||||
viewModel.selectCategory(categoryId)
|
||||
}
|
||||
val items by viewModel.itemsForSelectedCategory.collectAsStateWithLifecycle()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Articles") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) }
|
||||
) { padding ->
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(items, key = { it.itemId }) { item ->
|
||||
ItemTile(item = item, onClick = {
|
||||
viewModel.addItemToActiveList(item)
|
||||
scope.launch { snackbar.showSnackbar("${item.name} ajouté") }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(text = item.emoji, style = MaterialTheme.typography.displaySmall)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = "Ajouter",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.align(Alignment.TopEnd).size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CatalogSearchScreen(
|
||||
listId: Long,
|
||||
onBack: () -> Unit,
|
||||
viewModel: CatalogViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(listId) { viewModel.setActiveList(listId) }
|
||||
val query by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
val results by viewModel.searchResults.collectAsStateWithLifecycle()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Rechercher") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) }
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = viewModel::updateSearchQuery,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
placeholder = { Text("Tapez un produit…") },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = viewModel::clearSearch) {
|
||||
Icon(Icons.Filled.Clear, contentDescription = "Effacer")
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search)
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(results, key = { it.itemId }) { item ->
|
||||
SearchResultRow(item = item, onAdd = {
|
||||
viewModel.addItemToActiveList(item)
|
||||
scope.launch { snackbar.showSnackbar("${item.name} ajouté") }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onAdd),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = item.emoji, style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (item.aliases.isNotBlank()) {
|
||||
Text(
|
||||
text = item.aliases.replace("|", " · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onAdd) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Ajouter")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
package com.safebite.app.presentation.screen.catalog
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.safebite.app.data.local.database.entity.CatalogItemEntity
|
||||
import com.safebite.app.data.local.database.entity.CategoryEntity
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
||||
import com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems
|
||||
import com.safebite.app.data.repository.CatalogRepository
|
||||
import com.safebite.app.domain.usecase.ManageShoppingListUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel partagé par les écrans du module Catalogue (Domaines, Catégories,
|
||||
* Articles, Recherche). Reçoit l'`activeListId` via [setActiveList] de sorte
|
||||
* que l'ajout d'un article cible la bonne liste.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class CatalogViewModel @Inject constructor(
|
||||
private val repository: CatalogRepository,
|
||||
private val manageListUseCase: ManageShoppingListUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _activeListId = MutableStateFlow<Long?>(null)
|
||||
val activeListId: StateFlow<Long?> = _activeListId.asStateFlow()
|
||||
|
||||
val domains: StateFlow<List<DomainWithCategoriesAndItems>> =
|
||||
repository.observeDomainsWithCategoriesAndItems().stateIn(
|
||||
viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()
|
||||
)
|
||||
|
||||
private val _selectedDomainId = MutableStateFlow<String?>(null)
|
||||
val selectedDomainId: StateFlow<String?> = _selectedDomainId.asStateFlow()
|
||||
|
||||
val categoriesForSelectedDomain: StateFlow<List<CategoryEntity>> =
|
||||
_selectedDomainId
|
||||
.flatMapLatest { id ->
|
||||
if (id == null) flowOf(emptyList())
|
||||
else repository.observeCategoriesForDomain(id)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _selectedCategoryId = MutableStateFlow<String?>(null)
|
||||
val selectedCategoryId: StateFlow<String?> = _selectedCategoryId.asStateFlow()
|
||||
|
||||
val itemsForSelectedCategory: StateFlow<List<CatalogItemEntity>> =
|
||||
_selectedCategoryId
|
||||
.flatMapLatest { id ->
|
||||
if (id == null) flowOf(emptyList())
|
||||
else repository.observeItemsForCategory(id)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
val searchResults: StateFlow<List<CatalogItemEntity>> =
|
||||
_searchQuery
|
||||
.flatMapLatest { q ->
|
||||
if (q.isBlank()) flowOf(emptyList())
|
||||
else repository.search(q, limit = 30)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun setActiveList(listId: Long) {
|
||||
_activeListId.value = listId.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
fun selectDomain(domainId: String) {
|
||||
_selectedDomainId.value = domainId
|
||||
}
|
||||
|
||||
fun selectCategory(categoryId: String) {
|
||||
_selectedCategoryId.value = categoryId
|
||||
}
|
||||
|
||||
fun updateSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun clearSearch() {
|
||||
_searchQuery.value = ""
|
||||
}
|
||||
|
||||
/** Ajoute l'article du catalogue à la liste active courante. */
|
||||
fun addItemToActiveList(item: CatalogItemEntity, categoryNameOverride: String? = null) {
|
||||
val listId = _activeListId.value ?: return
|
||||
viewModelScope.launch {
|
||||
// Évite les doublons par nom (ignore-case).
|
||||
val existing = manageListUseCase.getItems(listId)
|
||||
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) }
|
||||
if (existing != null) {
|
||||
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||
return@launch
|
||||
}
|
||||
val categoryName = categoryNameOverride
|
||||
?: item.primaryCategoryId?.let { repository.getCategory(it)?.name }
|
||||
manageListUseCase.addItemToList(
|
||||
listId,
|
||||
ShoppingListItemEntity(
|
||||
listId = listId,
|
||||
productName = item.name,
|
||||
category = categoryName,
|
||||
customEmoji = item.emoji
|
||||
)
|
||||
)
|
||||
repository.incrementPopularity(item.itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Apps
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.Camera
|
||||
@ -127,6 +128,7 @@ fun ListDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenScanner: () -> Unit,
|
||||
onOpenProduct: (String) -> Unit,
|
||||
onOpenCatalog: (Long) -> Unit = {},
|
||||
viewModel: ListDetailViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(listId, listName) {
|
||||
@ -209,6 +211,9 @@ fun ListDetailScreen(
|
||||
IconButton(onClick = onOpenScanner) {
|
||||
Icon(Icons.Filled.Camera, contentDescription = "Scanner")
|
||||
}
|
||||
IconButton(onClick = { onOpenCatalog(listId) }) {
|
||||
Icon(Icons.Filled.Apps, contentDescription = "Catalogue")
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = "Options")
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
MAJOR=1
|
||||
MINOR=16
|
||||
PATCH=7
|
||||
CODE=27
|
||||
MINOR=18
|
||||
PATCH=0
|
||||
CODE=29
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user