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:
Bruno Charest 2026-04-29 08:52:19 -04:00
parent 2cef0e399c
commit bd9d01a6ee
18 changed files with 1642 additions and 7 deletions

View File

@ -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.** { *; }

View 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"}
]}
]}
]}

View File

@ -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") }
}
}
}

View File

@ -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"

View File

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

View File

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

View File

@ -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)")
}
}

View File

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

View File

@ -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"
}
}

View File

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

View File

@ -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
}
}

View File

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

View File

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

View File

@ -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"
}
}
/**

View File

@ -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")
}
}
}
}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
MAJOR=1
MINOR=16
PATCH=7
CODE=27
MINOR=18
PATCH=0
CODE=29