From bd9d01a6ee4f86e5e974f25d0a795f1a4493926f Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 29 Apr 2026 08:52:19 -0400 Subject: [PATCH] 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 --- app/proguard-rules.pro | 17 +- app/src/main/assets/catalog_seed.json | 418 ++++++++++++++++++ .../com/safebite/app/SafeBiteApplication.kt | 15 + .../data/local/database/SafeBiteDatabase.kt | 14 +- .../app/data/local/database/dao/CatalogDao.kt | 114 +++++ .../local/database/entity/CatalogEntities.kt | 99 +++++ .../local/database/migration/Migration7To8.kt | 80 ++++ .../database/relation/CatalogRelations.kt | 39 ++ .../app/data/local/seed/CatalogSeedManager.kt | 106 +++++ .../app/data/local/seed/CatalogSeedModels.kt | 44 ++ .../app/data/repository/CatalogRepository.kt | 82 ++++ .../com/safebite/app/di/DatabaseModule.kt | 4 + .../app/presentation/navigation/NavGraph.kt | 67 ++- .../app/presentation/navigation/Screen.kt | 14 + .../screen/catalog/CatalogScreens.kt | 404 +++++++++++++++++ .../screen/catalog/CatalogViewModel.kt | 121 +++++ .../screen/lists/ListDetailScreen.kt | 5 + version.properties | 6 +- 18 files changed, 1642 insertions(+), 7 deletions(-) create mode 100644 app/src/main/assets/catalog_seed.json create mode 100644 app/src/main/java/com/safebite/app/data/local/database/dao/CatalogDao.kt create mode 100644 app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt create mode 100644 app/src/main/java/com/safebite/app/data/local/database/migration/Migration7To8.kt create mode 100644 app/src/main/java/com/safebite/app/data/local/database/relation/CatalogRelations.kt create mode 100644 app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt create mode 100644 app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt create mode 100644 app/src/main/java/com/safebite/app/data/repository/CatalogRepository.kt create mode 100644 app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogScreens.kt create mode 100644 app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogViewModel.kt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 7160d4f..c2e1127 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 { + (...); + ; +} +-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.** { *; } diff --git a/app/src/main/assets/catalog_seed.json b/app/src/main/assets/catalog_seed.json new file mode 100644 index 0000000..ab813d8 --- /dev/null +++ b/app/src/main/assets/catalog_seed.json @@ -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"} +]} +]} +]} diff --git a/app/src/main/java/com/safebite/app/SafeBiteApplication.kt b/app/src/main/java/com/safebite/app/SafeBiteApplication.kt index 2478e40..f2c0942 100644 --- a/app/src/main/java/com/safebite/app/SafeBiteApplication.kt +++ b/app/src/main/java/com/safebite/app/SafeBiteApplication.kt @@ -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") } + } } } diff --git a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt index b47c693..1957f81 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt @@ -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" diff --git a/app/src/main/java/com/safebite/app/data/local/database/dao/CatalogDao.kt b/app/src/main/java/com/safebite/app/data/local/database/dao/CatalogDao.kt new file mode 100644 index 0000000..3851d5e --- /dev/null +++ b/app/src/main/java/com/safebite/app/data/local/database/dao/CatalogDao.kt @@ -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> + + @Transaction + @Query("SELECT * FROM shopping_domains WHERE isActive = 1 ORDER BY sortOrder") + fun getDomainsWithCategories(): Flow> + + @Transaction + @Query("SELECT * FROM shopping_domains WHERE isActive = 1 ORDER BY sortOrder") + fun getDomainsWithCategoriesAndItems(): Flow> + + @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> + + @Transaction + @Query("SELECT * FROM categories WHERE categoryId = :categoryId") + fun getCategoryWithItems(categoryId: String): Flow + + @Query("SELECT * FROM categories WHERE isActive = 1 ORDER BY sortOrder") + fun getAllCategories(): Flow> + + @Query("SELECT * FROM categories WHERE isActive = 1 ORDER BY sortOrder") + suspend fun getAllCategoriesNow(): List + + @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> + + @Query("SELECT * FROM catalog_items WHERE primaryCategoryId = :categoryId ORDER BY sortOrder, name") + fun getItemsForCategory(categoryId: String): Flow> + + @Query("SELECT * FROM catalog_items WHERE primaryCategoryId = :categoryId ORDER BY sortOrder, name") + suspend fun getItemsForCategoryNow(categoryId: String): List + + @Query("SELECT * FROM catalog_items ORDER BY popularity DESC, name ASC LIMIT :limit") + fun getPopularItems(limit: Int = 15): Flow> + + @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) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCategories(categories: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItems(items: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertCrossRefs(refs: List) + + // ── Articles utilisateur ────────────────────────────────────────────────── + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItem(item: CatalogItemEntity) +} diff --git a/app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt b/app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt new file mode 100644 index 0000000..775e84c --- /dev/null +++ b/app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt @@ -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 +) diff --git a/app/src/main/java/com/safebite/app/data/local/database/migration/Migration7To8.kt b/app/src/main/java/com/safebite/app/data/local/database/migration/Migration7To8.kt new file mode 100644 index 0000000..9df19d7 --- /dev/null +++ b/app/src/main/java/com/safebite/app/data/local/database/migration/Migration7To8.kt @@ -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)") + } +} diff --git a/app/src/main/java/com/safebite/app/data/local/database/relation/CatalogRelations.kt b/app/src/main/java/com/safebite/app/data/local/database/relation/CatalogRelations.kt new file mode 100644 index 0000000..997be37 --- /dev/null +++ b/app/src/main/java/com/safebite/app/data/local/database/relation/CatalogRelations.kt @@ -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 +) + +data class CategoryWithItems( + @Embedded val category: CategoryEntity, + @Relation( + parentColumn = "categoryId", + entityColumn = "itemId", + associateBy = Junction( + value = ItemCategoryCrossRef::class, + parentColumn = "categoryId", + entityColumn = "itemId" + ) + ) + val items: List +) + +data class DomainWithCategoriesAndItems( + @Embedded val domain: ShoppingDomainEntity, + @Relation( + entity = CategoryEntity::class, + parentColumn = "domainId", + entityColumn = "domainId" + ) + val categoriesWithItems: List +) diff --git a/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt new file mode 100644 index 0000000..5073d9c --- /dev/null +++ b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt @@ -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" + } +} diff --git a/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt new file mode 100644 index 0000000..0d8aac5 --- /dev/null +++ b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt @@ -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 +) + +@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 +) + +@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 +) + +@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 +) diff --git a/app/src/main/java/com/safebite/app/data/repository/CatalogRepository.kt b/app/src/main/java/com/safebite/app/data/repository/CatalogRepository.kt new file mode 100644 index 0000000..b390db2 --- /dev/null +++ b/app/src/main/java/com/safebite/app/data/repository/CatalogRepository.kt @@ -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> = dao.getAllDomains() + + fun observeDomainsWithCategories(): Flow> = + dao.getDomainsWithCategories() + + fun observeDomainsWithCategoriesAndItems(): Flow> = + dao.getDomainsWithCategoriesAndItems() + + fun observeCategoriesForDomain(domainId: String): Flow> = + dao.getCategoriesForDomain(domainId) + + fun observeCategoryWithItems(categoryId: String): Flow = + dao.getCategoryWithItems(categoryId) + + fun observeItemsForCategory(categoryId: String): Flow> = + dao.getItemsForCategory(categoryId) + + fun observePopularItems(limit: Int = 15): Flow> = + dao.getPopularItems(limit) + + fun search(query: String, limit: Int = 20): Flow> = + 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 + } +} diff --git a/app/src/main/java/com/safebite/app/di/DatabaseModule.kt b/app/src/main/java/com/safebite/app/di/DatabaseModule.kt index 35b2c3b..d8f02ba 100644 --- a/app/src/main/java/com/safebite/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/safebite/app/di/DatabaseModule.kt @@ -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() } diff --git a/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt b/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt index 10e0a59..6325a0d 100644 --- a/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt @@ -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() } ) } diff --git a/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt b/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt index 9b4937d..06c97ae 100644 --- a/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt @@ -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" + } } /** diff --git a/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogScreens.kt b/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogScreens.kt new file mode 100644 index 0000000..ddb36cd --- /dev/null +++ b/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogScreens.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogViewModel.kt new file mode 100644 index 0000000..2afe95d --- /dev/null +++ b/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogViewModel.kt @@ -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(null) + val activeListId: StateFlow = _activeListId.asStateFlow() + + val domains: StateFlow> = + repository.observeDomainsWithCategoriesAndItems().stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList() + ) + + private val _selectedDomainId = MutableStateFlow(null) + val selectedDomainId: StateFlow = _selectedDomainId.asStateFlow() + + val categoriesForSelectedDomain: StateFlow> = + _selectedDomainId + .flatMapLatest { id -> + if (id == null) flowOf(emptyList()) + else repository.observeCategoriesForDomain(id) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val _selectedCategoryId = MutableStateFlow(null) + val selectedCategoryId: StateFlow = _selectedCategoryId.asStateFlow() + + val itemsForSelectedCategory: StateFlow> = + _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 = _searchQuery.asStateFlow() + + val searchResults: StateFlow> = + _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) + } + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt index 95d5773..215e719 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt @@ -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") diff --git a/version.properties b/version.properties index 972bab3..099d005 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ MAJOR=1 -MINOR=16 -PATCH=7 -CODE=27 +MINOR=18 +PATCH=0 +CODE=29