diff --git a/.gitignore b/.gitignore
index 71af6b2..c68dca5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,6 @@
*.iml
.gradle
/local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
@@ -14,6 +8,9 @@
.cxx
local.properties
+# Android Studio / IntelliJ
+.idea/
+
.kotlin/
app/src/main/res/a
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 26d3352..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index 9028944..0000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-IngrediCheck
\ No newline at end of file
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
deleted file mode 100644
index 4a53bee..0000000
--- a/.idea/AndroidProjectSystem.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
deleted file mode 100644
index cc804ff..0000000
--- a/.idea/appInsightsSettings.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index b86273d..0000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
deleted file mode 100644
index 62599a1..0000000
--- a/.idea/deploymentTargetSelector.xml
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
deleted file mode 100644
index 91f9558..0000000
--- a/.idea/deviceManager.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index d124cf2..0000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 7061a0d..0000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
deleted file mode 100644
index f8051a6..0000000
--- a/.idea/migrations.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index b2c751a..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
deleted file mode 100644
index 16660f1..0000000
--- a/.idea/runConfigurations.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml
deleted file mode 100644
index 539e3b8..0000000
--- a/.idea/studiobot.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1dd..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index dce7420..c589ff7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -48,14 +48,14 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
- implementation("androidx.datastore:datastore-preferences:1.1.1")
+ implementation("androidx.datastore:datastore-preferences:1.2.0")
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
- implementation("androidx.compose.material:material-icons-extended")
- implementation("androidx.browser:browser:1.8.0")
+ implementation(libs.androidx.compose.material.icons.extended)
+ implementation("androidx.browser:browser:1.9.0")
implementation("androidx.startup:startup-runtime:1.1.1")
implementation("com.google.android.gms:play-services-auth:20.7.0")
implementation("io.ktor:ktor-client-okhttp:3.1.1")
@@ -63,6 +63,7 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.1")
+ implementation("com.airbnb.android:lottie-compose:6.7.1")
implementation("io.github.jan-tennert.supabase:supabase-kt:3.2.4")
implementation("io.github.jan-tennert.supabase:auth-kt:3.2.4")
implementation("io.github.jan-tennert.supabase:postgrest-kt:3.2.4")
diff --git a/app/src/main/assets/dynamicJsonData.json b/app/src/main/assets/dynamicJsonData.json
new file mode 100644
index 0000000..556f99a
--- /dev/null
+++ b/app/src/main/assets/dynamicJsonData.json
@@ -0,0 +1,845 @@
+{
+ "steps": [
+ {
+ "id": "allergies",
+ "type": "type-1",
+ "header": {
+ "iconUrl": "allergies",
+ "name": "Allergies",
+ "individual": {
+ "question": "Got any allergies we should keep in mind?",
+ "description": "Choose all that apply so we can give you smarter food tips."
+ },
+ "family": {
+ "question": "Does anyone in your IngrediFam have allergies we should know ?",
+ "description": "Select all that apply to keep meals worry-free."
+ },
+ "singleMember": {
+ "question": "Does {name} have any allergies we should keep in mind?",
+ "description": "Choose all that apply so we can give smarter food tips."
+ }
+ },
+ "content": {
+ "options": [
+ {
+ "name": "Peanuts",
+ "icon": "π₯"
+ },
+ {
+ "name": "Tree nuts",
+ "icon": "π°"
+ },
+ {
+ "name": "Dairy",
+ "icon": "π₯"
+ },
+ {
+ "name": "Eggs",
+ "icon": "π₯"
+ },
+ {
+ "name": "Soy",
+ "icon": "π±"
+ },
+ {
+ "name": "Wheat",
+ "icon": "πΎ"
+ },
+ {
+ "name": "Fish",
+ "icon": "π"
+ },
+ {
+ "name": "Shellfish",
+ "icon": "π¦"
+ },
+ {
+ "name": "Sesame",
+ "icon": "βͺ"
+ },
+ {
+ "name": "Celery",
+ "icon": "π₯¬"
+ },
+ {
+ "name": "Lupin",
+ "icon": "π«"
+ },
+ {
+ "name": "Sulphites",
+ "icon": "π§"
+ },
+ {
+ "name": "Mustard",
+ "icon": "π‘"
+ },
+ {
+ "name": "Molluscs",
+ "icon": "π"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ }
+ },
+ {
+ "id": "intolerances",
+ "type": "type-1",
+ "header": {
+ "iconUrl": "mingcute_alert-line",
+ "name": "Intolerances",
+ "individual": {
+ "question": "Any sensitivities that make eating tricky?",
+ "description": "We'll make sure your food suggestions avoid these."
+ },
+ "family": {
+ "question": "Any sensitivities or intolerances in your IngrediFam?",
+ "description": "We'll avoid foods that cause discomfort."
+ },
+ "singleMember": {
+ "question": "Does {name} have any food sensitivities?",
+ "description": "We'll make sure food suggestions avoid these."
+ }
+ },
+ "content": {
+ "options": [
+ {
+ "name": "Lactose",
+ "icon": "π₯"
+ },
+ {
+ "name": "Fructose",
+ "icon": "π"
+ },
+ {
+ "name": "Histamine",
+ "icon": "π·"
+ },
+ {
+ "name": "Gluten / wheat",
+ "icon": "πΎ"
+ },
+ {
+ "name": "Fodmap",
+ "icon": "π§"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ }
+ },
+ {
+ "id": "healthConditions",
+ "type": "type-1",
+ "header": {
+ "iconUrl": "lucide_stethoscope",
+ "name": "Health Conditions",
+ "individual": {
+ "question": "Do you follow any special diets or have health conditions?",
+ "description": "This helps us recommend meals that work for you."
+ },
+ "family": {
+ "question": "Any doctor diets or health conditions in your IngrediFam?",
+ "description": "This helps us tailor recommendations better."
+ },
+ "singleMember": {
+ "question": "Does {name} follow any special diets or have any health conditions?",
+ "description": "This helps us recommend meals that work better."
+ }
+ },
+ "content": {
+ "options": [
+ {
+ "name": "Diabetes",
+ "icon": "π"
+ },
+ {
+ "name": "Hypertension",
+ "icon": "π"
+ },
+ {
+ "name": "Kidney Disease",
+ "icon": "π©Ί"
+ },
+ {
+ "name": "Heart Health",
+ "icon": "π«"
+ },
+ {
+ "name": "PKU (phenylalanine-sensitive)",
+ "icon": "π§¬"
+ },
+ {
+ "name": "Anti-inflammatory medical diet",
+ "icon": "π₯"
+ },
+ {
+ "name": "Celiac disease",
+ "icon": "π₯"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ }
+ },
+ {
+ "id": "lifeStage",
+ "type": "type-1",
+ "header": {
+ "iconUrl": "lucide_baby",
+ "name": "Life Stage",
+ "individual": {
+ "question": "Do you have special needs we should keep in mind?",
+ "description": "Select all that apply, this helps us tailor tips for you."
+ },
+ "family": {
+ "question": "Does anyone in your IngrediFam have special life stage needs?",
+ "description": "Select all that apply so tips match every life stage."
+ },
+ "singleMember": {
+ "question": "Any specific needs for {name}?",
+ "description": "Select all that apply so tips match their life stage."
+ }
+ },
+ "content": {
+ "options": [
+ {
+ "name": "Kids Baby-friendly foods",
+ "icon": "πΆ"
+ },
+ {
+ "name": "Toddler picky-eating adaptations",
+ "icon": "π"
+ },
+ {
+ "name": "Pregnancy Prenatal nutrition",
+ "icon": "π€°"
+ },
+ {
+ "name": "Breastfeeding diets",
+ "icon": "πΌ"
+ },
+ {
+ "name": "Senior-friendly",
+ "icon": "π΄"
+ },
+ {
+ "name": "None of these apply",
+ "icon": "β
"
+ }
+ ]
+ }
+ },
+ {
+ "id": "region",
+ "type": "type-3",
+ "header": {
+ "iconUrl": "nrk_globe",
+ "name": "Region",
+ "individual": {
+ "question": "Where are you from? This helps us customize your experience!",
+ "description": "Pick your region(s) or cultural practices."
+ },
+ "family": {
+ "question": "Where does your IngrediFam draw its food traditions from?",
+ "description": "Select your region or cultural roots."
+ },
+ "singleMember": {
+ "question": "Where is {name} from? It helps us personalize the experience.",
+ "description": "Pick region(s) or cultural practices."
+ }
+ },
+ "content": {
+ "regions": [
+ {
+ "name": "India & South Asia",
+ "subRegions": [
+ {
+ "name": "Ayurveda",
+ "icon": "πΏ"
+ },
+ {
+ "name": "Hindu food traditions",
+ "icon": "π"
+ },
+ {
+ "name": "Jain diet",
+ "icon": "π§ββοΈ"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ },
+ {
+ "name": "Africa",
+ "subRegions": [
+ {
+ "name": "Rastafarian Ital diet",
+ "icon": "π₯"
+ },
+ {
+ "name": "Ethiopian Orthodox fasting",
+ "icon": "π₯"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ },
+ {
+ "name": "Middle East & Mediterranean",
+ "subRegions": [
+ {
+ "name": "Halal (Islamic dietary laws)",
+ "icon": "βͺοΈ"
+ },
+ {
+ "name": "Kosher (Jewish dietary laws)",
+ "icon": "β‘οΈ"
+ },
+ {
+ "name": "Greek / Mediterranean diets",
+ "icon": "π«"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ },
+ {
+ "name": "East Asia",
+ "subRegions": [
+ {
+ "name": "Traditional Chinese Medicine (TCM)",
+ "icon": "π§§"
+ },
+ {
+ "name": "Buddhist food rules",
+ "icon": "π§"
+ },
+ {
+ "name": "Japanese Macrobiotic diet",
+ "icon": "π"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ },
+ {
+ "name": "Western / Native traditions",
+ "subRegions": [
+ {
+ "name": "Native American traditions",
+ "icon": "πͺΆ"
+ },
+ {
+ "name": "Christian traditions",
+ "icon": "βοΈ"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ },
+ {
+ "name": "Seventh-day Adventist",
+ "subRegions": [
+ {
+ "name": "Seventh-day Adventist",
+ "icon": "βοΈ"
+ }
+ ]
+ },
+ {
+ "name": "Other",
+ "subRegions": [
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "avoid",
+ "type": "type-2",
+ "header": {
+ "iconUrl": "charm_circle-cross",
+ "name": "Avoid",
+ "individual": {
+ "question": "Anything you avoid in your diet?"
+ },
+ "family": {
+ "question": "Anything your IngrediFam avoids?"
+ },
+ "singleMember": {
+ "question": "Anything to avoid in {name}'s diet?"
+ }
+ },
+ "content": {
+ "subSteps": [
+ {
+ "id": "avoid_oils_fats",
+ "title": "Oils & Fats",
+ "description": "In fats or oils, what do you avoid?",
+ "color": "#FFF6B3",
+ "bgImageUrl": "leaf-recycle",
+ "options": [
+ {
+ "name": "Hydrogenated oils / Trans fats",
+ "icon": "π§"
+ },
+ {
+ "name": "Canola / Seed oils",
+ "icon": "πΎ"
+ },
+ {
+ "name": "Palm oil",
+ "icon": "π΄"
+ },
+ {
+ "name": "Corn / High-fructose corn syrup",
+ "icon": "π½"
+ }
+ ]
+ },
+ {
+ "id": "avoid_animal_based",
+ "title": "Animal-Based",
+ "description": "Any animal products you don't consume?",
+ "color": "#DCC7F6",
+ "bgImageUrl": "leaf-recycle",
+ "options": [
+ {
+ "name": "Pork",
+ "icon": "π"
+ },
+ {
+ "name": "Beef",
+ "icon": "π"
+ },
+ {
+ "name": "Honey",
+ "icon": "π―"
+ },
+ {
+ "name": "Gelatin / Rennet",
+ "icon": "π§"
+ },
+ {
+ "name": "Shellfish",
+ "icon": "π¦"
+ },
+ {
+ "name": "Insects",
+ "icon": "π"
+ },
+ {
+ "name": "Seafood (fish)",
+ "icon": "π"
+ },
+ {
+ "name": "Lard / Animal fat",
+ "icon": "π"
+ }
+ ]
+ },
+ {
+ "id": "avoid_stimulants_substances",
+ "title": "Stimulants & Substances",
+ "description": "Do you avoid these?",
+ "color": "#BFF0D4",
+ "bgImageUrl": "leaf-recycle",
+ "options": [
+ {
+ "name": "Alcohol",
+ "icon": "π·"
+ },
+ {
+ "name": "Caffeine",
+ "icon": "β"
+ }
+ ]
+ },
+ {
+ "id": "avoid_additives_sweeteners",
+ "title": "Additives & Sweeteners",
+ "description": "Do you stay away from processed ingredients?",
+ "color": "#FFD9B5",
+ "bgImageUrl": "leaf-recycle",
+ "options": [
+ {
+ "name": "MSG",
+ "icon": "βοΈ"
+ },
+ {
+ "name": "Artificial sweeteners",
+ "icon": "π¬"
+ },
+ {
+ "name": "Preservatives",
+ "icon": "π§"
+ },
+ {
+ "name": "Refined sugar",
+ "icon": "π"
+ },
+ {
+ "name": "Corn syrup / HFCS",
+ "icon": "π½"
+ },
+ {
+ "name": "Stevia / Monk fruit",
+ "icon": "π"
+ }
+ ]
+ },
+ {
+ "id": "avoid_plant_based_restrictions",
+ "title": "Plant-Based Restrictions",
+ "description": "Any plant foods you avoid?",
+ "color": "#F9C6D0",
+ "bgImageUrl": "leaf-recycle",
+ "options": [
+ {
+ "name": "Nightshades (paprika, peppers, etc.)",
+ "icon": "π
"
+ },
+ {
+ "name": "Garlic / Onion",
+ "icon": "π§"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "lifeStyle",
+ "type": "type-2",
+ "header": {
+ "iconUrl": "hugeicons_plant-01",
+ "name": "Life Style",
+ "individual": {
+ "question": "What's your way of eating?"
+ },
+ "family": {
+ "question": "What's your IngrediFam's food lifestyle?"
+ },
+ "singleMember": {
+ "question": "How does {name} prefer to eat?"
+ }
+ },
+ "content": {
+ "subSteps": [
+ {
+ "id": "lifestyle_plant_balance",
+ "title": "Plant & Balance",
+ "description": "Do you follow a plant-forward or flexible eating style?",
+ "color": "#FFF6B3",
+ "bgImageUrl": "leaf-recycle",
+ "options": [
+ {
+ "name": "Vegetarian",
+ "icon": "π₯¦"
+ },
+ {
+ "name": "Vegan",
+ "icon": "π±"
+ },
+ {
+ "name": "Flexitarian",
+ "icon": "π"
+ },
+ {
+ "name": "Reducetarian",
+ "icon": "β"
+ },
+ {
+ "name": "Pescatarian",
+ "icon": "π"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ },
+ {
+ "id": "lifestyle_quality_source",
+ "title": "Quality & Source",
+ "description": "Do you care about where your food comes from and how it's grown?",
+ "color": "#DCC7F6",
+ "bgImageUrl": "leaf-recycle",
+ "options": [
+ {
+ "name": "Organic Only",
+ "icon": "π±"
+ },
+ {
+ "name": "Non-GMO",
+ "icon": "π§¬"
+ },
+ {
+ "name": "Locally Sourced",
+ "icon": "π"
+ },
+ {
+ "name": "Seasonal Eater",
+ "icon": "π°οΈ"
+ }
+ ]
+ },
+ {
+ "id": "lifestyle_sustainable_living",
+ "title": "Sustainable Living",
+ "description": "Are you mindful of waste, packaging, and ingredient transparency?",
+ "color": "#D7EEB2",
+ "bgImageUrl": "leaf-recycle",
+ "options": [
+ {
+ "name": "Zero-Waste / Minimal Packing",
+ "icon": "π"
+ },
+ {
+ "name": "Clean Label",
+ "icon": "β
"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "nutrition",
+ "type": "type-2",
+ "header": {
+ "iconUrl": "fluent-emoji-high-contrast_fork-and-knife-with-plate",
+ "name": "Nutrition",
+ "individual": {
+ "question": "What's your nutrition focus right now?"
+ },
+ "family": {
+ "question": "What's your IngrediFam's nutrition focus?"
+ },
+ "singleMember": {
+ "question": "What's {name}'s nutrition focus right now?"
+ }
+ },
+ "content": {
+ "subSteps": [
+ {
+ "id": "nutrition_macronutrient_goals",
+ "title": "Macronutrient Goals",
+ "description": "Do you want to balance your proteins, carbs, and fats or focus on one?",
+ "color": "#F9C6D0",
+ "bgImageUrl": "leaf-recycle",
+ "options": [
+ {
+ "name": "High Protein",
+ "icon": "π"
+ },
+ {
+ "name": "Low Carb",
+ "icon": "π₯"
+ },
+ {
+ "name": "Low Fat",
+ "icon": "π₯"
+ },
+ {
+ "name": "Balanced Macros",
+ "icon": "βοΈ"
+ }
+ ]
+ },
+ {
+ "id": "nutrition_sugar_fiber",
+ "title": "Sugar & Fiber",
+ "description": "Do you prefer low sugar or high-fiber foods for better digestion and energy?",
+ "color": "#A7D8F0",
+ "bgImageUrl": "leaf-recycle",
+ "options": [
+ {
+ "name": "Low Sugar",
+ "icon": "π"
+ },
+ {
+ "name": "Sugar-Free",
+ "icon": "π"
+ },
+ {
+ "name": "High Fiber",
+ "icon": "πΎ"
+ }
+ ]
+ },
+ {
+ "id": "nutrition_diet_frameworks_patterns",
+ "title": "Diet Frameworks & Patterns",
+ "description": "Do you follow a structured eating plan or experiment with fasting?",
+ "color": "#FFD9B5",
+ "bgImageUrl": "leaf-recycle",
+ "options": [
+ {
+ "name": "Keto",
+ "icon": "π₯"
+ },
+ {
+ "name": "DASH",
+ "icon": "π§"
+ },
+ {
+ "name": "Paleo",
+ "icon": "π₯©"
+ },
+ {
+ "name": "Mediterranean",
+ "icon": "π«"
+ },
+ {
+ "name": "Whole30",
+ "icon": "π₯"
+ },
+ {
+ "name": "Fasting",
+ "icon": "π"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "ethical",
+ "type": "type-1",
+ "header": {
+ "iconUrl": "streamline_recycle-1-solid",
+ "name": "Ethical",
+ "individual": {
+ "question": "What ethical or environmental values are important to you?",
+ "description": "Select the causes that matter most when it comes to the food you eat."
+ },
+ "family": {
+ "question": "What ethical or environmental values matter to your IngrediFam?",
+ "description": "Select causes that shape your food choices."
+ },
+ "singleMember": {
+ "question": "What ethical or environmental priorities does {name} have?",
+ "description": "Select causes that shape food choices."
+ }
+ },
+ "content": {
+ "options": [
+ {
+ "name": "Animal welfare focused",
+ "icon": "π"
+ },
+ {
+ "name": "Fair trade",
+ "icon": "π€"
+ },
+ {
+ "name": "Sustainable fishing / no overfished species",
+ "icon": "π"
+ },
+ {
+ "name": "Low carbon footprint foods",
+ "icon": "β»οΈ"
+ },
+ {
+ "name": "Water footprint concerns",
+ "icon": "π§"
+ },
+ {
+ "name": "Palm-oil free",
+ "icon": "π΄"
+ },
+ {
+ "name": "Plastic-free packaging",
+ "icon": "π«"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ }
+ ]
+ }
+ },
+ {
+ "id": "taste",
+ "type": "type-1",
+ "header": {
+ "iconUrl": "iconoir_chocolate",
+ "name": "Taste",
+ "individual": {
+ "question": "What are your taste and texture preferences?",
+ "description": "Choose what you love or avoid when it comes to flavors and textures."
+ },
+ "family": {
+ "question": "What tastes and textures does your family prefer?",
+ "description": "Customize tastes so every plate feels just right."
+ },
+ "singleMember": {
+ "question": "What tastes and textures does {name} prefer?",
+ "description": "Customize tastes so every plate feels just right."
+ }
+ },
+ "content": {
+ "options": [
+ {
+ "name": "Spicy lover",
+ "icon": "πΆοΈ"
+ },
+ {
+ "name": "Avoid Spicy",
+ "icon": "π«"
+ },
+ {
+ "name": "Sweet tooth",
+ "icon": "π°"
+ },
+ {
+ "name": "Avoid slimy textures",
+ "icon": "π₯"
+ },
+ {
+ "name": "Avoid bitter foods",
+ "icon": "π΅"
+ },
+ {
+ "name": "Other",
+ "icon": "βοΈ"
+ },
+ {
+ "name": "Crunchy / Soft preferences",
+ "icon": "πͺ"
+ },
+ {
+ "name": "Low-sweet preference",
+ "icon": "π―"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
index f31436a..a052d35 100644
Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/lc/fungee/Ingredicheck/auth/AppleLoginWebViewActivity.kt b/app/src/main/java/lc/fungee/Ingredicheck/auth/AppleLoginWebViewActivity.kt
index 1d11c71..afce38c 100644
--- a/app/src/main/java/lc/fungee/Ingredicheck/auth/AppleLoginWebViewActivity.kt
+++ b/app/src/main/java/lc/fungee/Ingredicheck/auth/AppleLoginWebViewActivity.kt
@@ -17,6 +17,8 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
+import com.russhwolf.settings.BuildConfig
+
class AppleLoginWebViewActivity : ComponentActivity() {
@@ -71,8 +73,10 @@ class AppleLoginWebViewActivity : ComponentActivity() {
.toString()
}
- Log.d("AppleWebView", "Starting Apple WebView auth. redirectUri=$redirectUri")
- Log.d("AppleWebView", "Auth URL: ${sanitizeUrl(authUrl)}")
+ if (BuildConfig.DEBUG) {
+ Log.d("AppleWebView", "Starting Apple WebView auth. redirectUri=$redirectUri")
+ Log.d("AppleWebView", "Auth URL: ${sanitizeUrl(authUrl)}")
+ }
webView.webChromeClient = object : WebChromeClient() {
override fun onCreateWindow(
@@ -98,7 +102,9 @@ class AppleLoginWebViewActivity : ComponentActivity() {
val uri = Uri.parse(url)
val resultIntent = Intent()
- Log.d("AppleWebView", "Redirect detected: ${sanitizeUrl(url)}")
+ if (BuildConfig.DEBUG) {
+ Log.d("AppleWebView", "Redirect detected: ${sanitizeUrl(url)}")
+ }
val error = uri.getQueryParameter("error")
val errorDescription = uri.getQueryParameter("error_description")
@@ -146,7 +152,9 @@ class AppleLoginWebViewActivity : ComponentActivity() {
return true
}
if (url != null) {
- Log.d("AppleWebView", "Navigating: ${sanitizeUrl(url)}")
+ if (BuildConfig.DEBUG) {
+ Log.d("AppleWebView", "Navigating: ${sanitizeUrl(url)}")
+ }
}
return false
}
@@ -158,21 +166,27 @@ class AppleLoginWebViewActivity : ComponentActivity() {
return true
}
- Log.d("AppleWebView", "Navigating: ${sanitizeUrl(url)}")
+ if (BuildConfig.DEBUG) {
+ Log.d("AppleWebView", "Navigating: ${sanitizeUrl(url)}")
+ }
return false
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
if (url != null) {
- Log.d("AppleWebView", "Page started: ${sanitizeUrl(url)}")
+ if (BuildConfig.DEBUG) {
+ Log.d("AppleWebView", "Page started: ${sanitizeUrl(url)}")
+ }
}
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
if (url != null) {
- Log.d("AppleWebView", "Page finished: ${sanitizeUrl(url)}")
+ if (BuildConfig.DEBUG) {
+ Log.d("AppleWebView", "Page finished: ${sanitizeUrl(url)}")
+ }
}
}
diff --git a/app/src/main/java/lc/fungee/Ingredicheck/auth/AuthViewModel.kt b/app/src/main/java/lc/fungee/Ingredicheck/auth/AuthViewModel.kt
index dcba791..e829982 100644
--- a/app/src/main/java/lc/fungee/Ingredicheck/auth/AuthViewModel.kt
+++ b/app/src/main/java/lc/fungee/Ingredicheck/auth/AuthViewModel.kt
@@ -5,6 +5,7 @@ import android.net.Uri
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
+import com.russhwolf.settings.BuildConfig
import io.github.jan.supabase.auth.user.UserSession
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -18,6 +19,10 @@ import lc.fungee.Ingredicheck.memoji.MemojiRepository
import lc.fungee.Ingredicheck.memoji.MemojiRequestMapper
import lc.fungee.Ingredicheck.family.FamilyMemberDto
import lc.fungee.Ingredicheck.dietary.DietaryPreferenceRepository
+import lc.fungee.Ingredicheck.foodnotes.FoodNotesRepository
+import lc.fungee.Ingredicheck.onboarding.data.EVERYONE_MEMBER_ID
+import lc.fungee.Ingredicheck.onboarding.data.OnboardingChipData
+
enum class AuthProvider {
Google,
@@ -45,6 +50,7 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
private val memojiRepository = MemojiRepository()
private val familyRepository = FamilyRepository()
private val dietaryPreferenceRepository = DietaryPreferenceRepository()
+ private val foodNotesRepository = FoodNotesRepository()
private val _state = MutableStateFlow(AuthState.Idle)
val state: StateFlow = _state
@@ -61,7 +67,9 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
private fun pushDebug(message: String) {
val line = "${System.currentTimeMillis()} | $message"
_debugLog.value = (_debugLog.value + line).takeLast(60)
- Log.d("AuthDebug", message)
+ if (BuildConfig.DEBUG) {
+ Log.d("AuthDebug", message)
+ }
}
/**
@@ -71,7 +79,9 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
*/
fun syncDietaryPreferencesFromOnboarding(preferenceText: String) {
if (preferenceText.isBlank()) {
- Log.d("AuthDebug", "DietaryPreference: skip sync (empty text)")
+ if (BuildConfig.DEBUG) {
+ Log.d("AuthDebug", "DietaryPreference: skip sync (empty text)")
+ }
return
}
viewModelScope.launch {
@@ -93,7 +103,9 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
when (validationResult) {
is lc.fungee.Ingredicheck.dietary.PreferenceValidationResult.Success -> {
pushDebug("DietaryPreference: sync success id=${validationResult.preference.id}")
- Log.d("AuthDebug", "DietaryPreference: sync success id=${validationResult.preference.id}")
+ if (BuildConfig.DEBUG) {
+ Log.d("AuthDebug", "DietaryPreference: sync success id=${validationResult.preference.id}")
+ }
}
is lc.fungee.Ingredicheck.dietary.PreferenceValidationResult.Failure -> {
pushDebug("DietaryPreference: sync failure ${validationResult.explanation}")
@@ -109,6 +121,71 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
}
}
+ /**
+ * Sync onboarding chip selections to food-notes API (per-member and Everyone), matching iOS.
+ * Called when user taps "All Set!" or completes the last preference step.
+ * - EVERYONE_MEMBER_ID ("ALL") -> PUT family/food-notes
+ * - Each member id -> PUT family/members/{id}/food-notes
+ */
+ fun syncFoodNotesFromOnboarding(selectedAllergiesByMember: Map>) {
+ val keys = selectedAllergiesByMember.keys.toList()
+ if (BuildConfig.DEBUG) {
+ Log.d("FoodNotesAPI", "FoodNotes API implementation: sync started, keys=${keys}, size=${selectedAllergiesByMember.size}")
+ }
+ if (selectedAllergiesByMember.isEmpty()) {
+ if (BuildConfig.DEBUG) {
+ Log.d("FoodNotesAPI", "FoodNotes API: skip sync (empty selections)")
+ }
+ return
+ }
+ viewModelScope.launch {
+ val accessToken = repository.accessTokenOrNull()
+ if (accessToken.isNullOrBlank()) {
+ Log.w("FoodNotesAPI", "FoodNotes API: skip sync (no access token)")
+ return@launch
+ }
+ var successCount = 0
+ var failCount = 0
+ for ((memberKey, chipIds) in selectedAllergiesByMember) {
+ if (chipIds.isEmpty()) continue
+ val content = OnboardingChipData.buildFoodNotesContentFromChipIds(chipIds)
+ if (content.isEmpty()) continue
+ val isEveryone = memberKey == EVERYONE_MEMBER_ID || memberKey.isBlank()
+ val result = if (isEveryone) {
+ foodNotesRepository.updateFamilyFoodNotes(
+ accessToken = accessToken,
+ content = content,
+ version = 0
+ )
+ } else {
+ foodNotesRepository.updateMemberFoodNotes(
+ accessToken = accessToken,
+ memberId = memberKey.lowercase(),
+ content = content,
+ version = 0
+ )
+ }
+ result.fold(
+ onSuccess = {
+ successCount++
+ pushDebug("FoodNotes: sync success ${if (isEveryone) "Everyone" else memberKey}")
+ if (BuildConfig.DEBUG) {
+ Log.d("FoodNotesAPI", "FoodNotes API implementation: sync success ${if (isEveryone) "Everyone" else "member=$memberKey"} β working")
+ }
+ },
+ onFailure = { e ->
+ failCount++
+ pushDebug("FoodNotes: sync error ${if (isEveryone) "Everyone" else memberKey} ${e.message}")
+ Log.e("FoodNotesAPI", "FoodNotes API: sync error ${if (isEveryone) "Everyone" else memberKey}", e)
+ }
+ )
+ }
+ if (BuildConfig.DEBUG) {
+ Log.d("FoodNotesAPI", "FoodNotes API implementation: sync completed β success=$successCount fail=$failCount (check logs above for details)")
+ }
+ }
+ }
+
fun addFamilyMember(member: FamilyMemberDto, onResult: (Result) -> Unit) {
viewModelScope.launch {
val accessToken = repository.accessTokenOrNull()
diff --git a/app/src/main/java/lc/fungee/Ingredicheck/dietary/DIETARY_PREFERENCE.md b/app/src/main/java/lc/fungee/Ingredicheck/dietary/DIETARY_PREFERENCE.md
index f1665e1..db8dd98 100644
--- a/app/src/main/java/lc/fungee/Ingredicheck/dietary/DIETARY_PREFERENCE.md
+++ b/app/src/main/java/lc/fungee/Ingredicheck/dietary/DIETARY_PREFERENCE.md
@@ -28,4 +28,28 @@
- `DietaryPreference`: GET/POST/PUT/DELETE and status/body length
- `AuthDebug`: `DietaryPreference: syncing...`, `sync success id=...`, `sync failure...`, or `sync error...`
-Filter logcat by `DietaryPreference`, `OnboardingAllergies`, or `AuthDebug` to confirm itβs working.
+Filter logcat by `DietaryPreference`, `OnboardingAllergies`, or `AuthDebug` to confirm it's working.
+
+---
+
+## Food-notes API (per-member / Everyone) β matches iOS
+
+Onboarding chip selections are also synced to the **food-notes** API so that each family member (and "Everyone") has their own note on the backend, matching iOS behavior.
+
+1. **Endpoints**
+ - `PUT family/food-notes` (body: `{ "content": {...}, "version": 0 }`) β "Everyone" / family note
+ - `PUT family/members/{memberId}/food-notes` β one member's note
+ - `GET family/food-notes/all` β load all (family + members) for versions/cache
+
+2. **When we sync**
+ - Same as above: when the user taps "All Set!" or completes the last preference step.
+ - For each key in `selectedAllergiesByMember`: **ALL** β `updateFamilyFoodNotes`; member UUID β `updateMemberFoodNotes(memberId.lowercase(), ...)`.
+
+3. **Where it runs**
+ - **OnboardingHost**: Calls `authViewModel.syncFoodNotesFromOnboarding(...)` in both exit paths (with dietary preference sync).
+ - **AuthViewModel**: `syncFoodNotesFromOnboarding` builds content via `OnboardingChipData.buildFoodNotesContentFromChipIds(chipIds)` and calls `FoodNotesRepository` for each member/Everyone.
+ - **FoodNotesRepository**: PUT requests; on 409 retries once with `currentNote.version` or `version = 0`.
+
+4. **Logs**
+ - `FoodNotes`: updateFamilyFoodNotes, updateMemberFoodNotes, status and retries.
+ - `AuthDebug`: `FoodNotes: sync success Everyone` / `FoodNotes: sync success `.
diff --git a/app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceRepository.kt b/app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceRepository.kt
index 8ea5355..6b7d770 100644
--- a/app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceRepository.kt
+++ b/app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceRepository.kt
@@ -1,6 +1,7 @@
package lc.fungee.Ingredicheck.dietary
import android.util.Log
+import com.russhwolf.settings.BuildConfig
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
@@ -23,6 +24,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import lc.fungee.Ingredicheck.AppConfig
+
private const val TAG = "DietaryPreference"
/**
@@ -65,21 +67,27 @@ class DietaryPreferenceRepository {
/** GET list of dietary preferences. */
suspend fun getDietaryPreferences(accessToken: String): Result> {
val url = baseUrl("preferencelists/default")
- Log.d(TAG, "getDietaryPreferences: GET $url")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "getDietaryPreferences: GET $url")
+ }
return runCatching {
val response: HttpResponse = client.get(url) {
authHeaders(accessToken).forEach { (k, v) -> header(k, v) }
accept(ContentType.Application.Json)
}
val body = response.bodyAsText()
- Log.d(TAG, "getDietaryPreferences: status=${response.status.value} bodyLength=${body.length}")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "getDietaryPreferences: status=${response.status.value} bodyLength=${body.length}")
+ }
if (!response.status.isSuccess()) {
Log.e(TAG, "getDietaryPreferences: failed ${response.status.value} $body")
throw IllegalStateException("GET failed: ${response.status.value}")
}
json.decodeFromString>(body)
}.onSuccess { list ->
- Log.d(TAG, "getDietaryPreferences: success count=${list.size}")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "getDietaryPreferences: success count=${list.size}")
+ }
}.onFailure { e ->
Log.e(TAG, "getDietaryPreferences: error", e)
}
@@ -98,7 +106,9 @@ class DietaryPreferenceRepository {
val path = if (id != null) "preferencelists/default/$id" else "preferencelists/default"
val method = if (id != null) "PUT" else "POST"
val url = baseUrl(path)
- Log.d(TAG, "addOrEditDietaryPreference: $method $url clientActivityId=$clientActivityId preferenceLength=${preferenceText.length} id=$id")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "addOrEditDietaryPreference: $method $url clientActivityId=$clientActivityId preferenceLength=${preferenceText.length} id=$id")
+ }
return runCatching {
val formBody = MultiPartFormDataContent(formData {
append("clientActivityId", clientActivityId)
@@ -119,7 +129,9 @@ class DietaryPreferenceRepository {
}
val res = response as HttpResponse
val body = res.bodyAsText()
- Log.d(TAG, "addOrEditDietaryPreference: status=${res.status.value} bodyLength=${body.length}")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "addOrEditDietaryPreference: status=${res.status.value} bodyLength=${body.length}")
+ }
if (!res.status.isSuccess() && res.status.value !in 200..299 && res.status.value != 422) {
Log.e(TAG, "addOrEditDietaryPreference: bad status ${res.status.value} $body")
throw IllegalStateException("Request failed: ${res.status.value}")
@@ -128,7 +140,9 @@ class DietaryPreferenceRepository {
}.onSuccess { result ->
when (result) {
is PreferenceValidationResult.Success ->
- Log.d(TAG, "addOrEditDietaryPreference: success id=${result.preference.id}")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "addOrEditDietaryPreference: success id=${result.preference.id}")
+ }
is PreferenceValidationResult.Failure ->
Log.w(TAG, "addOrEditDietaryPreference: failure explanation=${result.explanation}")
}
@@ -144,7 +158,9 @@ class DietaryPreferenceRepository {
id: Int
): Result {
val url = baseUrl("preferencelists/default/$id")
- Log.d(TAG, "deleteDietaryPreference: DELETE $url id=$id")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "deleteDietaryPreference: DELETE $url id=$id")
+ }
return runCatching {
val formBody = MultiPartFormDataContent(formData {
append("clientActivityId", clientActivityId)
@@ -153,7 +169,9 @@ class DietaryPreferenceRepository {
authHeaders(accessToken).forEach { (k, v) -> header(k, v) }
setBody(formBody)
}
- Log.d(TAG, "deleteDietaryPreference: status=${response.status.value}")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "deleteDietaryPreference: status=${response.status.value}")
+ }
if (response.status.value != 204 && !response.status.isSuccess()) {
throw IllegalStateException("DELETE failed: ${response.status.value}")
}
diff --git a/app/src/main/java/lc/fungee/Ingredicheck/foodnotes/FoodNotesDto.kt b/app/src/main/java/lc/fungee/Ingredicheck/foodnotes/FoodNotesDto.kt
new file mode 100644
index 0000000..6c4069a
--- /dev/null
+++ b/app/src/main/java/lc/fungee/Ingredicheck/foodnotes/FoodNotesDto.kt
@@ -0,0 +1,26 @@
+package lc.fungee.Ingredicheck.foodnotes
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonObject
+
+/**
+ * Response from GET family/food-notes/all.
+ * Matches iOS FoodNotesAllResponse.
+ */
+@Serializable
+data class FoodNotesAllResponse(
+ val familyNote: FoodNotesResponse? = null,
+ val memberNotes: Map = emptyMap()
+)
+
+/**
+ * Single food note (family or member): content + version for optimistic locking.
+ * Matches iOS FoodNotesResponse.
+ */
+@Serializable
+data class FoodNotesResponse(
+ val content: JsonObject,
+ val version: Int,
+ @SerialName("updatedAt") val updatedAt: String = ""
+)
diff --git a/app/src/main/java/lc/fungee/Ingredicheck/foodnotes/FoodNotesRepository.kt b/app/src/main/java/lc/fungee/Ingredicheck/foodnotes/FoodNotesRepository.kt
new file mode 100644
index 0000000..8446e00
--- /dev/null
+++ b/app/src/main/java/lc/fungee/Ingredicheck/foodnotes/FoodNotesRepository.kt
@@ -0,0 +1,262 @@
+package lc.fungee.Ingredicheck.foodnotes
+
+import android.util.Log
+import com.russhwolf.settings.BuildConfig
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.request.accept
+import io.ktor.client.request.get
+import io.ktor.client.request.header
+import io.ktor.client.request.put
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.bodyAsText
+import io.ktor.client.statement.HttpResponse
+import io.ktor.http.ContentType
+import io.ktor.http.isSuccess
+import java.util.concurrent.TimeUnit
+import kotlinx.serialization.Serializable
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.buildJsonObject
+import lc.fungee.Ingredicheck.AppConfig
+
+
+private const val TAG = "FoodNotes"
+private const val MAX_VERSION_RETRY = 2
+
+/**
+ * Food-notes API: per-member and "Everyone" chip selections (allergies, intolerances, etc.).
+ * Matches iOS: PUT family/food-notes for Everyone, PUT family/members/{id}/food-notes per member.
+ */
+class FoodNotesRepository {
+
+ private val json = Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ }
+
+ private val client = HttpClient(OkHttp) {
+ install(HttpTimeout) {
+ requestTimeoutMillis = 30_000
+ connectTimeoutMillis = 15_000
+ socketTimeoutMillis = 30_000
+ }
+ engine {
+ config {
+ connectTimeout(15, TimeUnit.SECONDS)
+ readTimeout(30, TimeUnit.SECONDS)
+ writeTimeout(30, TimeUnit.SECONDS)
+ }
+ }
+ }
+
+ private fun baseUrl(path: String): String {
+ val base = AppConfig.supabaseFunctionsURLBase
+ return if (base.endsWith("/")) base + path else "$base$path"
+ }
+
+ private fun authHeaders(accessToken: String): Map = mapOf(
+ "apikey" to AppConfig.supabaseKey,
+ "Authorization" to "Bearer $accessToken",
+ "Content-Type" to "application/json"
+ )
+
+ @Serializable
+ data class FoodNotesItem(val name: String, val iconName: String = "")
+
+ @Serializable
+ data class FoodNotesUpdateRequest(
+ val content: Map>,
+ val version: Int
+ )
+
+ /**
+ * Convert onboarding content (stepId -> list of { name, iconName }) to API request format.
+ */
+ private fun toRequestContent(content: Map>>): Map> =
+ content.mapValues { (_, list) ->
+ list.map { m -> FoodNotesItem(name = m["name"] ?: "", iconName = m["iconName"] ?: "") }
+ }
+
+ /**
+ * GET family/food-notes/all β load family note + all member notes (for versions/cache).
+ */
+ suspend fun fetchFoodNotesAll(accessToken: String): Result {
+ val url = baseUrl("family/food-notes/all")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "fetchFoodNotesAll: GET $url")
+ }
+ return runCatching {
+ val response: HttpResponse = client.get(url) {
+ authHeaders(accessToken).forEach { (k, v) -> header(k, v) }
+ accept(ContentType.Application.Json)
+ }
+ val body = response.bodyAsText()
+ when {
+ response.status.value == 404 -> {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "FoodNotes API: fetchFoodNotesAll 404, no notes yet")
+ }
+ return@runCatching null
+ }
+ body.isBlank() || body.trim() == "null" -> {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "FoodNotes API: fetchFoodNotesAll null/empty body")
+ }
+ return@runCatching null
+ }
+ !response.status.isSuccess() -> {
+ Log.e(TAG, "FoodNotes API: fetchFoodNotesAll failed ${response.status.value} $body")
+ throw IllegalStateException("GET failed: ${response.status.value}")
+ }
+ else -> {
+ val parsed = json.decodeFromString(body)
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "FoodNotes API implementation: fetchFoodNotesAll success β data loaded")
+ }
+ parsed
+ }
+ }
+ }.onFailure { e -> Log.e(TAG, "FoodNotes API: fetchFoodNotesAll error", e) }
+ }
+
+ /**
+ * PUT family/food-notes β update "Everyone" / family note.
+ * On 409 (version_mismatch), retries once with currentNote.version if present, else version=0.
+ * @param content Map from step id to list of { "name", "iconName" } (e.g. from OnboardingChipData.buildFoodNotesContentFromChipIds).
+ */
+ suspend fun updateFamilyFoodNotes(
+ accessToken: String,
+ content: Map>>,
+ version: Int,
+ retryCount: Int = 0
+ ): Result {
+ val requestContent = toRequestContent(content)
+ val url = baseUrl("family/food-notes")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "FoodNotes API: updateFamilyFoodNotes PUT $url version=$version")
+ }
+ return runCatching {
+ val body = json.encodeToString(FoodNotesUpdateRequest(requestContent, version))
+ val response: HttpResponse = client.put(url) {
+ authHeaders(accessToken).forEach { (k, v) -> header(k, v) }
+ accept(ContentType.Application.Json)
+ setBody(body)
+ }
+ val responseBody = response.bodyAsText()
+ if (response.status.isSuccess()) {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "FoodNotes API implementation: updateFamilyFoodNotes success (Everyone) β working")
+ }
+ }
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "FoodNotes API: updateFamilyFoodNotes status=${response.status.value}")
+ }
+ when {
+ response.status.isSuccess() -> parseFoodNotesResponse(responseBody)
+ response.status.value == 409 -> {
+ if (retryCount >= MAX_VERSION_RETRY) {
+ Log.w(TAG, "updateFamilyFoodNotes: 409 conflict after $MAX_VERSION_RETRY retries, giving up")
+ throw IllegalStateException("version conflict (family) after $MAX_VERSION_RETRY retries")
+ }
+ val currentVersion = parseVersionFrom409Response(responseBody)
+ val nextVersion = currentVersion ?: 0
+ if (BuildConfig.DEBUG) {
+ Log.d(
+ TAG,
+ "updateFamilyFoodNotes: 409 retry=${retryCount + 1} with version=$nextVersion (currentVersion=$currentVersion)"
+ )
+ }
+ updateFamilyFoodNotes(
+ accessToken = accessToken,
+ content = content,
+ version = nextVersion,
+ retryCount = retryCount + 1
+ ).getOrThrow()
+ }
+ else -> throw IllegalStateException("PUT failed: ${response.status.value} $responseBody")
+ }
+ }.onFailure { e -> Log.e(TAG, "updateFamilyFoodNotes: error", e) }
+ }
+
+ /**
+ * PUT family/members/{memberId}/food-notes β update one member's note.
+ * @param content Map from step id to list of { "name", "iconName" } (e.g. from OnboardingChipData.buildFoodNotesContentFromChipIds).
+ */
+ suspend fun updateMemberFoodNotes(
+ accessToken: String,
+ memberId: String,
+ content: Map>>,
+ version: Int,
+ retryCount: Int = 0
+ ): Result {
+ val requestContent = toRequestContent(content)
+ val url = baseUrl("family/members/$memberId/food-notes")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "FoodNotes API: updateMemberFoodNotes PUT $url version=$version")
+ }
+ return runCatching {
+ val body = json.encodeToString(FoodNotesUpdateRequest(requestContent, version))
+ val response: HttpResponse = client.put(url) {
+ authHeaders(accessToken).forEach { (k, v) -> header(k, v) }
+ accept(ContentType.Application.Json)
+ setBody(body)
+ }
+ val responseBody = response.bodyAsText()
+ if (response.status.isSuccess()) {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "FoodNotes API implementation: updateMemberFoodNotes success (memberId=$memberId) β working")
+ }
+ }
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "FoodNotes API: updateMemberFoodNotes status=${response.status.value}")
+ }
+ when {
+ response.status.isSuccess() -> parseFoodNotesResponse(responseBody)
+ response.status.value == 409 -> {
+ if (retryCount >= MAX_VERSION_RETRY) {
+ Log.w(TAG, "updateMemberFoodNotes: 409 conflict after $MAX_VERSION_RETRY retries (memberId=$memberId), giving up")
+ throw IllegalStateException("version conflict (memberId=$memberId) after $MAX_VERSION_RETRY retries")
+ }
+ val currentVersion = parseVersionFrom409Response(responseBody)
+ val nextVersion = currentVersion ?: 0
+ if (BuildConfig.DEBUG) {
+ Log.d(
+ TAG,
+ "updateMemberFoodNotes: 409 retry=${retryCount + 1} with version=$nextVersion (currentVersion=$currentVersion)"
+ )
+ }
+ updateMemberFoodNotes(
+ accessToken = accessToken,
+ memberId = memberId,
+ content = content,
+ version = nextVersion,
+ retryCount = retryCount + 1
+ ).getOrThrow()
+ }
+ else -> throw IllegalStateException("PUT failed: ${response.status.value} $responseBody")
+ }
+ }.onFailure { e -> Log.e(TAG, "updateMemberFoodNotes: error", e) }
+ }
+
+ private fun parseFoodNotesResponse(body: String): FoodNotesResponse {
+ val obj = json.parseToJsonElement(body).jsonObject
+ val version = obj["version"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0
+ val updatedAt = obj["updatedAt"]?.jsonPrimitive?.content ?: ""
+ val content = obj["content"]?.jsonObject ?: buildJsonObject { }
+ return FoodNotesResponse(content = content, version = version, updatedAt = updatedAt)
+ }
+
+ private fun parseVersionFrom409Response(body: String): Int? {
+ return try {
+ val obj = json.parseToJsonElement(body).jsonObject
+ val currentNote = obj["currentNote"]?.jsonObject ?: return null
+ currentNote["version"]?.jsonPrimitive?.content?.toIntOrNull()
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/DynamicStepsDto.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/DynamicStepsDto.kt
new file mode 100644
index 0000000..120305c
--- /dev/null
+++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/DynamicStepsDto.kt
@@ -0,0 +1,62 @@
+package lc.fungee.Ingredicheck.onboarding.data
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/** Root payload from dynamicJsonData.json (same structure as iOS). */
+@Serializable
+data class DynamicStepsPayload(
+ val steps: List
+)
+
+@Serializable
+data class DynamicStep(
+ val id: String,
+ val type: String,
+ val header: DynamicStepHeader,
+ val content: DynamicStepContent
+)
+
+@Serializable
+data class DynamicStepHeader(
+ val iconUrl: String,
+ val name: String,
+ val individual: DynamicHeaderVariant,
+ val family: DynamicHeaderVariant,
+ val singleMember: DynamicHeaderVariant? = null
+)
+
+@Serializable
+data class DynamicHeaderVariant(
+ val question: String,
+ val description: String? = null
+)
+
+@Serializable
+data class DynamicStepContent(
+ val options: List? = null,
+ val subSteps: List? = null,
+ val regions: List? = null
+)
+
+@Serializable
+data class DynamicOption(
+ val name: String,
+ val icon: String
+)
+
+@Serializable
+data class DynamicSubStep(
+ val id: String,
+ val title: String,
+ val description: String,
+ val color: String,
+ @SerialName("bgImageUrl") val bgImageUrl: String,
+ val options: List? = null
+)
+
+@Serializable
+data class DynamicRegion(
+ val name: String,
+ val subRegions: List
+)
diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/DynamicStepsLoader.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/DynamicStepsLoader.kt
new file mode 100644
index 0000000..b04abe0
--- /dev/null
+++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/DynamicStepsLoader.kt
@@ -0,0 +1,68 @@
+package lc.fungee.Ingredicheck.onboarding.data
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import java.io.IOException
+
+private const val ASSET_FILE = "dynamicJsonData.json"
+private const val TAG = "DynamicJsonData"
+
+private val json = Json { ignoreUnknownKeys = true }
+
+/**
+ * Loads and caches onboarding steps from assets/dynamicJsonData.json (same as iOS).
+ * Call [ensureLoaded] when you have a context (e.g. from OnboardingHost); then
+ * [OnboardingChipData] will use dynamic steps when [getSteps] is non-null.
+ */
+object DynamicStepsLoader {
+
+ @Volatile
+ private var cachedSteps: List? = null
+
+ /** Returns cached steps if already loaded; null if not loaded or load failed. */
+ fun getSteps(): List? = cachedSteps
+
+ /** Loads from assets if not yet loaded. Safe to call multiple times. */
+ suspend fun ensureLoaded(context: Context): List? {
+ // Fast path: already cached in memory.
+ cachedSteps?.let {
+ Log.d(TAG, "JSON data: using cached steps (count=${it.size}), already loaded β UI will use same data after restart")
+ return it
+ }
+
+ // Do disk I/O + JSON parsing on a background thread so we don't block the UI.
+ val steps = withContext(Dispatchers.IO) {
+ loadFromAssets(context)
+ }
+
+ if (steps != null) {
+ cachedSteps = steps
+ Log.d(TAG, "JSON data: loaded successfully from assets. steps count=${steps.size}, stepIds=${steps.map { it.id }}")
+ } else {
+ Log.w(TAG, "JSON data: load failed β no steps available; UI will have empty onboarding steps")
+ }
+ return steps
+ }
+
+ private fun loadFromAssets(context: Context): List? {
+ Log.d(TAG, "JSON data: loading from assets/$ASSET_FILE...")
+ return try {
+ context.assets.open(ASSET_FILE).use { input ->
+ val text = input.bufferedReader().use { it.readText() }
+ val payload = json.decodeFromString(text)
+ val steps = payload.steps
+ Log.d(TAG, "JSON data: parse OK. steps count=${steps.size} β all cases (including after restart) will use this data")
+ steps
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "JSON data: load failed (IOException) ${e.message}", e)
+ null
+ } catch (e: kotlinx.serialization.SerializationException) {
+ Log.e(TAG, "JSON data: load failed (parse error) ${e.message}", e)
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/OnboardingData.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/OnboardingData.kt
index 36df614..5ae6188 100644
--- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/OnboardingData.kt
+++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/OnboardingData.kt
@@ -5,6 +5,16 @@ import lc.fungee.Ingredicheck.R
/** Member id used for "Everyone" in add-family and just-me flows. */
const val EVERYONE_MEMBER_ID = "ALL"
+/**
+ * Flow type for onboarding questions - determines which question text to show.
+ */
+enum class OnboardingFlowType {
+ /** "Just Me" / Individual flow */
+ INDIVIDUAL,
+ /** "Add Family" flow */
+ FAMILY
+}
+
/**
* Static configuration for the multiβstep fineβtune flow (allergy/sensitivity/health/lifeβstage chips)
* and shared avatar lists. Keeping this data out of the UI layer keeps
@@ -46,368 +56,144 @@ data class AvoidCardDefinition(
object OnboardingChipData {
+ private fun slug(name: String): String =
+ name.lowercase().replace(Regex("[^a-z0-9]"), "_").replace(Regex("_+"), "_").trim('_')
+
+ private fun dynamicStepToChips(step: DynamicStep): List {
+ return when (step.type) {
+ "type-1" -> step.content.options?.map { o ->
+ ChipDefinition(slug(o.name), o.name, o.icon + " ")
+ } ?: emptyList()
+ "type-2" -> step.content.subSteps?.flatMap { sub ->
+ (sub.options ?: emptyList()).map { o ->
+ ChipDefinition("${sub.id}_${slug(o.name)}", o.name, o.icon + " ")
+ }
+ } ?: emptyList()
+ "type-3" -> step.content.regions?.flatMap { r ->
+ r.subRegions.map { o ->
+ ChipDefinition("${slug(r.name)}_${slug(o.name)}", o.name, o.icon + " ")
+ }
+ } ?: emptyList()
+ else -> emptyList()
+ }
+ }
+
+ private fun dynamicSteps(): List? = DynamicStepsLoader.getSteps()
+
/**
* Returns the (question, subtitle) pair for the given fineβtune step.
+ * @param step Step index (0-9)
+ * @param flowType Flow type (INDIVIDUAL for "Just Me", FAMILY for "Add Family")
*/
- fun questionForStep(step: Int): Pair {
- return when (step.coerceIn(0, 9)) {
- 0 -> "Does anyone in your IngrediFam have allergies we should know?" to
- "Select all that apply to keep meals worry-free."
- 1 -> "Any sensitivities or intolerances in your IngrediFam?" to
- "Weβll avoid foods that cause discomfort."
- 2 -> "Any doctor diets or health conditions in your IngrediFam?" to
- "This helps us tailor recommendations better."
- 3 -> "Does anyone in your IngrediFam have special life stage needs?" to
- "Select all that apply so tips match every life stage."
- // 4 β Region / cultural practices
- 4 -> "Where are you from? This helps us customize your experience!" to
- "Pick your region(s) or cultural practices."
- 5 -> "Anything your IngrediFam avoids?" to
- "Weβll steer clear of those ingredients and products."
- // 6 β LifeStyle (plant/balance, quality/source, sustainable living)
- 6 -> "Whatβs your lifestyle when it comes to food?" to
- "Tell us about your eating style, sourcing, and habits."
- // 7 β Nutrition (macros, sugar/fiber, diet frameworks)
- 7 -> "How do you like to approach nutrition?" to
- "Set your macronutrient goals, sugar & fiber preferences, and diet patterns."
- else -> "Does anyone in your IngrediFam have allergies we should know?" to
- "Select all that apply to keep meals worry-free."
+ fun questionForStep(step: Int, flowType: OnboardingFlowType = OnboardingFlowType.FAMILY): Pair {
+ val steps = dynamicSteps()
+ if (steps != null && step in steps.indices) {
+ val h = steps[step].header
+ val v = if (flowType == OnboardingFlowType.INDIVIDUAL) h.individual else h.family
+ return v.question to (v.description ?: "")
}
+ return "" to ""
}
/**
* Returns the chip set (id, label, emoji prefix) for the given fineβtune step.
*/
fun chipsForStep(step: Int): List {
- return when (step.coerceIn(0, 9)) {
- // 0 β Allergies
- 0 -> listOf(
- ChipDefinition("peanuts", "Peanuts", "π₯ "),
- ChipDefinition("tree_nuts", "Tree nuts", "π° "),
- ChipDefinition("dairy", "Dairy", "π₯ "),
- ChipDefinition("eggs", "Eggs", "π₯ "),
- ChipDefinition("soy", "Soy", "π± "),
- ChipDefinition("wheat", "Wheat", "πΎ "),
- ChipDefinition("fish", "Fish", "π "),
- ChipDefinition("shellfish", "Shellfish", "π€ "),
- ChipDefinition("sesame", "Sesame", "β¨ "),
- ChipDefinition("celery", "Celery", "π₯¬ "),
- ChipDefinition("lupin", "Lupin", "π« "),
- ChipDefinition("sulphites", "Sulphites", "π§ "),
- ChipDefinition("mustard", "Mustard", "π‘ "),
- ChipDefinition("molluscs", "Molluscs", "π "),
- ChipDefinition("other", "Other", "βοΈ ")
- )
-
- // 1 β Sensitivities / intolerances
- 1 -> listOf(
- ChipDefinition("lactose", "Lactose", "π₯ "),
- ChipDefinition("fructose", "Fructose", "π "),
- ChipDefinition("histamine", "Histamine", "π· "),
- ChipDefinition("gluten_wheat", "Gluten / wheat", "πΎ "),
- ChipDefinition("fodmap", "FODMAP", "π§ "),
- ChipDefinition("other_sens", "Other", "βοΈ ")
- )
-
- // 2 β Health conditions / doctor diets
- 2 -> listOf(
- ChipDefinition("diabetes", "Diabetes", "π "),
- ChipDefinition("hypertension", "Hypertension", "π "),
- ChipDefinition("kidney_disease", "Kidney disease", "π©Ί "),
- ChipDefinition("heart_health", "Heart health", "\uD83E\uDEC0 "),
- ChipDefinition("pku", "PKU (phenylalanine-sensitive)", "𧬠"),
- ChipDefinition("anti_inflammatory", "Anti-inflammatory / medical diet", "π₯ "),
- ChipDefinition("celiac_disease", "Celiac disease", "π₯ "),
- ChipDefinition("other_health", "Other", "βοΈ ")
- )
-
- // 3 β Life stage needs
- 3 -> listOf(
- ChipDefinition("kids_baby_friendly", "Kids / Baby-friendly foods", "πΆ "),
- ChipDefinition("toddler_picky", "Toddler picky-eating adaptations", "π "),
- ChipDefinition("pregnancy_prenatal", "Pregnancy / Prenatal nutrition", "π€° "),
- ChipDefinition("breastfeeding", "Breastfeeding diets", "πΌ "),
- ChipDefinition("senior_friendly", "Senior-friendly", "π΄ "),
- ChipDefinition("none_lifestage", "None of these apply", "β
")
- )
-
- // 4 β Region / cultural practices (subRegions as chips for capsule display)
- 4 -> regions.flatMap { it.subRegions }
+ val steps = dynamicSteps()
+ if (steps != null && step in steps.indices) {
+ return dynamicStepToChips(steps[step])
+ }
+ return emptyList()
+ }
- // 5 β Avoid (stacked card options as chips for capsule display)
- 5 -> avoidCards.flatMap { it.options }.map { o ->
- ChipDefinition(o.id, o.label, o.iconPrefix)
+ // Avoid stacked cards (type-2) from dynamic JSON "avoid" step.
+ val avoidCards: List
+ get() {
+ dynamicSteps()?.find { it.id == "avoid" }?.content?.subSteps?.let { subSteps ->
+ return subSteps.map { sub ->
+ AvoidCardDefinition(
+ id = sub.id,
+ title = sub.title,
+ description = sub.description,
+ colorHex = sub.color,
+ options = (sub.options ?: emptyList()).map { o ->
+ AvoidOptionDefinition("${sub.id}_${slug(o.name)}", o.name, o.icon + " ")
+ }
+ )
+ }
}
+ return emptyList()
+ }
- // 6 β LifeStyle (stacked card options for capsule display)
- 6 -> lifestyleCards.flatMap { it.options }.map { o ->
- ChipDefinition(o.id, o.label, o.iconPrefix)
+ /** LifeStyle stacked cards from dynamic JSON "lifeStyle" step. */
+ val lifestyleCards: List
+ get() {
+ dynamicSteps()?.find { it.id == "lifeStyle" }?.content?.subSteps?.let { subSteps ->
+ return subSteps.map { sub ->
+ AvoidCardDefinition(
+ id = sub.id,
+ title = sub.title,
+ description = sub.description,
+ colorHex = sub.color,
+ options = (sub.options ?: emptyList()).map { o ->
+ AvoidOptionDefinition("${sub.id}_${slug(o.name)}", o.name, o.icon + " ")
+ }
+ )
+ }
}
+ return emptyList()
+ }
- // 7 β Nutrition (stacked card options for capsule display)
- 7 -> nutritionCards.flatMap { it.options }.map { o ->
- ChipDefinition(o.id, o.label, o.iconPrefix)
+ /** Nutrition stacked cards from dynamic JSON "nutrition" step. */
+ val nutritionCards: List
+ get() {
+ dynamicSteps()?.find { it.id == "nutrition" }?.content?.subSteps?.let { subSteps ->
+ return subSteps.map { sub ->
+ AvoidCardDefinition(
+ id = sub.id,
+ title = sub.title,
+ description = sub.description,
+ colorHex = sub.color,
+ options = (sub.options ?: emptyList()).map { o ->
+ AvoidOptionDefinition("${sub.id}_${slug(o.name)}", o.name, o.icon + " ")
+ }
+ )
+ }
}
-
- // 8 β Ethical preferences
- 8 -> listOf(
- ChipDefinition("ethical_animal_welfare", "Animal welfare focused", "π "),
- ChipDefinition("ethical_fair_trade", "Fair trade", "π€ "),
- ChipDefinition(
- "ethical_sustainable_fishing",
- "Sustainable fishing / no overfished species",
- "π "
- ),
- ChipDefinition("ethical_low_carbon", "Low carbon footprint foods", "β»οΈ "),
- ChipDefinition("ethical_water_footprint", "Water footprint concerns", "π§ "),
- ChipDefinition("ethical_palm_oil_free", "Palm-oil free", "π΄ "),
- ChipDefinition("ethical_plastic_free_packaging", "Plastic-free packaging", "π« "),
- ChipDefinition("ethical_other", "Other", "βοΈ ")
- )
-
- // 9 β Taste preferences
- 9 -> listOf(
- ChipDefinition("taste_spicy_lover", "Spicy lover", "πΆοΈ "),
- ChipDefinition("taste_avoid_spicy", "Avoid Spicy", "π« "),
- ChipDefinition("taste_sweet_tooth", "Sweet tooth", "π° "),
- ChipDefinition("taste_avoid_slimy", "Avoid slimy textures", "π₯ "),
- ChipDefinition("taste_avoid_bitter", "Avoid bitter foods", "π΅ "),
- ChipDefinition("taste_other", "Other", "βοΈ "),
- ChipDefinition("taste_crunchy_soft", "Crunchy / Soft preferences", "πͺ "),
- ChipDefinition("taste_low_sweet", "Low-sweet preference", "π― ")
- )
-
- else -> chipsForStep(0)
+ return emptyList()
}
- }
- // Avoid stacked cards (type-2) used for the Avoid step.
- val avoidCards: List = listOf(
- AvoidCardDefinition(
- id = "avoid_oils_fats",
- title = "Oils & Fats",
- description = "In fats or oils, what do you avoid?",
- colorHex = "#FFF6B3",
- options = listOf(
- AvoidOptionDefinition("avoid_oils_trans_fats", "Hydrogenated oils / Trans fats", "π§ "),
- AvoidOptionDefinition("avoid_oils_seed", "Canola / Seed oils", "πΎ "),
- AvoidOptionDefinition("avoid_oils_palm", "Palm oil", "π΄ "),
- AvoidOptionDefinition("avoid_oils_corn_hfcs", "Corn / High-fructose corn syrup", "π½ ")
- )
- ),
- AvoidCardDefinition(
- id = "avoid_animal_based",
- title = "Animal-Based",
- description = "Any animal products you don't consume?",
- colorHex = "#DCC7F6",
- options = listOf(
- AvoidOptionDefinition("avoid_animal_pork", "Pork", "π "),
- AvoidOptionDefinition("avoid_animal_beef", "Beef", "π "),
- AvoidOptionDefinition("avoid_animal_honey", "Honey", "π― "),
- AvoidOptionDefinition("avoid_animal_gelatin", "Gelatin / Rennet", "π§ "),
- AvoidOptionDefinition("avoid_animal_shellfish", "Shellfish", "π¦ "),
- AvoidOptionDefinition("avoid_animal_insects", "Insects", "π "),
- AvoidOptionDefinition("avoid_animal_seafood", "Seafood (fish)", "π "),
- AvoidOptionDefinition("avoid_animal_lard", "Lard / Animal fat", "π ")
- )
- ),
- AvoidCardDefinition(
- id = "avoid_stimulants_substances",
- title = "Stimulants & Substances",
- description = "Do you avoid these?",
- colorHex = "#BFF0D4",
- options = listOf(
- AvoidOptionDefinition("avoid_stim_alcohol", "Alcohol", "π· "),
- AvoidOptionDefinition("avoid_stim_caffeine", "Caffeine", "β ")
- )
- ),
- AvoidCardDefinition(
- id = "avoid_additives_sweeteners",
- title = "Additives & Sweeteners",
- description = "Do you stay away from processed ingredients?",
- colorHex = "#FFD9B5",
- options = listOf(
- AvoidOptionDefinition("avoid_add_msg", "MSG", "βοΈ "),
- AvoidOptionDefinition("avoid_add_artificial_sweeteners", "Artificial sweeteners", "π¬ "),
- AvoidOptionDefinition("avoid_add_preservatives", "Preservatives", "π§ "),
- AvoidOptionDefinition("avoid_add_refined_sugar", "Refined sugar", "π "),
- AvoidOptionDefinition("avoid_add_corn_syrup", "Corn syrup / HFCS", "π½ "),
- AvoidOptionDefinition("avoid_add_stevia_monk", "Stevia / Monk fruit", "π ")
- )
- ),
- AvoidCardDefinition(
- id = "avoid_plant_based_restrictions",
- title = "Plant-Based Restrictions",
- description = "Any plant foods you avoid?",
- colorHex = "#F9C6D0",
- options = listOf(
- AvoidOptionDefinition("avoid_plant_nightshades", "Nightshades (paprika, peppers, etc.)", "π
"),
- AvoidOptionDefinition("avoid_plant_garlic_onion", "Garlic / Onion", "π§ ")
- )
- )
- )
-
- /** LifeStyle stacked cards (3 cards): Plant & Balance, Quality & Source, Sustainable Living. */
- val lifestyleCards: List = listOf(
- AvoidCardDefinition(
- id = "lifestyle_plant_balance",
- title = "Plant & Balance",
- description = "Do you follow a plant-forward or flexible eating style?",
- colorHex = "#FFF6B3",
- options = listOf(
- AvoidOptionDefinition("lifestyle_plant_vegetarian", "Vegetarian", "π₯¦ "),
- AvoidOptionDefinition("lifestyle_plant_vegan", "Vegan", "π± "),
- AvoidOptionDefinition("lifestyle_plant_flexitarian", "Flexitarian", "π "),
- AvoidOptionDefinition("lifestyle_plant_reducetarian", "Reducetarian", "β "),
- AvoidOptionDefinition("lifestyle_plant_pescatarian", "Pescatarian", "π "),
- AvoidOptionDefinition("lifestyle_plant_other", "Other", "βοΈ ")
- )
- ),
- AvoidCardDefinition(
- id = "lifestyle_quality_source",
- title = "Quality & Source",
- description = "Do you care about where your food comes from and how it's grown?",
- colorHex = "#DCC7F6",
- options = listOf(
- AvoidOptionDefinition("lifestyle_quality_organic", "Organic Only", "π± "),
- AvoidOptionDefinition("lifestyle_quality_nongmo", "Non-GMO", "𧬠"),
- AvoidOptionDefinition("lifestyle_quality_local", "Locally Sourced", "π "),
- AvoidOptionDefinition("lifestyle_quality_seasonal", "Seasonal Eater", "π°οΈ ")
- )
- ),
- AvoidCardDefinition(
- id = "lifestyle_sustainable_living",
- title = "Sustainable Living",
- description = "Are you mindful of waste, packaging, and ingredient transparency?",
- colorHex = "#D7EEB2",
- options = listOf(
- AvoidOptionDefinition("lifestyle_sustainable_zerowaste", "Zero-Waste / Minimal Packing", "π "),
- AvoidOptionDefinition("lifestyle_sustainable_clean_label", "Clean Label", "β
")
- )
- )
- )
-
- /** Nutrition stacked cards (3 cards): Macronutrient Goals, Sugar & Fiber, Diet Frameworks & Patterns. */
- val nutritionCards: List = listOf(
- AvoidCardDefinition(
- id = "nutrition_macronutrient_goals",
- title = "Macronutrient Goals",
- description = "Do you want to balance your proteins, carbs, and fats or focus on one?",
- colorHex = "#F9C6D0",
- options = listOf(
- AvoidOptionDefinition("nutrition_macro_high_protein", "High Protein", "π "),
- AvoidOptionDefinition("nutrition_macro_low_carb", "Low Carb", "π₯ "),
- AvoidOptionDefinition("nutrition_macro_low_fat", "Low Fat", "π₯ "),
- AvoidOptionDefinition("nutrition_macro_balanced", "Balanced Macros", "βοΈ ")
- )
- ),
- AvoidCardDefinition(
- id = "nutrition_sugar_fiber",
- title = "Sugar & Fiber",
- description = "Do you prefer low sugar or high-fiber foods for better digestion and energy?",
- colorHex = "#A7D8F0",
- options = listOf(
- AvoidOptionDefinition("nutrition_sugar_low", "Low Sugar", "π "),
- AvoidOptionDefinition("nutrition_sugar_free", "Sugar-Free", "π "),
- AvoidOptionDefinition("nutrition_fiber_high", "High Fiber", "πΎ ")
- )
- ),
- AvoidCardDefinition(
- id = "nutrition_diet_frameworks_patterns",
- title = "Diet Frameworks & Patterns",
- description = "Do you follow a structured eating plan or experiment with fasting?",
- colorHex = "#FFD9B5",
- options = listOf(
- AvoidOptionDefinition("nutrition_diet_keto", "Keto", "π₯ "),
- AvoidOptionDefinition("nutrition_diet_dash", "DASH", "π§ "),
- AvoidOptionDefinition("nutrition_diet_paleo", "Paleo", "π₯© "),
- AvoidOptionDefinition("nutrition_diet_mediterranean", "Mediterranean", "π« "),
- AvoidOptionDefinition("nutrition_diet_whole30", "Whole30", "π₯ "),
- AvoidOptionDefinition("nutrition_diet_fasting", "Fasting", "π "),
- AvoidOptionDefinition("nutrition_diet_other", "Other", "βοΈ ")
- )
- )
- )
-
- /**
- * Static definition of cultural / regional food traditions used on the
- * "Where does your IngrediFam draw its food traditions from?" step.
- *
- * Mirrors the iOS `regions` JSON structure (DynamicRegionsQuestionView),
- * but reuses `ChipDefinition` for subβregions so selections behave like
- * normal chips on Android.
- */
- val regions: List = listOf(
- RegionDefinition(
- name = "India & South Asia",
- subRegions = listOf(
- ChipDefinition("region_india_ayurveda", "Ayurveda", "πΏ "),
- ChipDefinition("region_india_hindu_traditions", "Hindu food traditions", "π "),
- ChipDefinition("region_india_jain_diet", "Jain diet", "π§ββοΈ "),
- ChipDefinition("region_india_other", "Other", "βοΈ ")
- )
- ),
- RegionDefinition(
- name = "Africa",
- subRegions = listOf(
- ChipDefinition("region_africa_rastafarian_ital", "Rastafarian Ital diet", "π₯ "),
- ChipDefinition("region_africa_ethiopian_orthodox", "Ethiopian Orthodox fasting", "π₯ "),
- ChipDefinition("region_africa_other", "Other", "βοΈ ")
- )
- ),
- RegionDefinition(
- name = "Middle East & Mediterranean",
- subRegions = listOf(
- ChipDefinition("region_middleeast_halal", "Halal (Islamic dietary laws)", "βͺοΈ "),
- ChipDefinition("region_middleeast_kosher", "Kosher (Jewish dietary laws)", "β‘οΈ "),
- ChipDefinition("region_middleeast_mediterranean", "Greek / Mediterranean diets", "π« "),
- ChipDefinition("region_middleeast_other", "Other", "βοΈ ")
- )
- ),
- RegionDefinition(
- name = "East Asia",
- subRegions = listOf(
- ChipDefinition("region_eastasia_tcm", "Traditional Chinese Medicine (TCM)", "π§§ "),
- ChipDefinition("region_eastasia_buddhist_rules", "Buddhist food rules", "π§ "),
- ChipDefinition("region_eastasia_macrobiotic", "Japanese Macrobiotic diet", "π "),
- ChipDefinition("region_eastasia_other", "Other", "βοΈ ")
- )
- ),
- RegionDefinition(
- name = "Western / Native traditions",
- subRegions = listOf(
- ChipDefinition("region_western_native_american", "Native American traditions", "πͺΆ "),
- ChipDefinition("region_western_christian", "Christian traditions", "βοΈ "),
- ChipDefinition("region_western_other", "Other", "βοΈ ")
- )
- ),
- RegionDefinition(
- name = "Seventh-day Adventist",
- subRegions = listOf(
- ChipDefinition("region_sda_seventh_day_adventist", "Seventh-day Adventist", "βοΈ ")
- )
- ),
- RegionDefinition(
- name = "Other",
- subRegions = listOf(
- ChipDefinition("region_other_other", "Other", "βοΈ ")
- )
- )
- )
+ /** Cultural / regional food traditions from dynamic JSON "region" step. */
+ val regions: List
+ get() {
+ dynamicSteps()?.find { it.id == "region" }?.content?.regions?.let { regs ->
+ return regs.map { r ->
+ RegionDefinition(
+ name = r.name,
+ subRegions = r.subRegions.map { o ->
+ ChipDefinition("${slug(r.name)}_${slug(o.name)}", o.name, o.icon + " ")
+ }
+ )
+ }
+ }
+ return emptyList()
+ }
/**
* Resolves a chip id to its definition (label + emoji) from any step.
* Used to display selected chips in the CapsuleSkeletonBox.
*/
fun chipForId(id: String): ChipDefinition? {
- for (step in 0..9) {
+ val steps = dynamicSteps()
+ val maxStep = (steps?.size ?: 0) - 1
+ for (step in 0..maxStep) {
chipsForStep(step).find { it.id == id }?.let { return it }
}
return null
}
/**
- * Shared avatar lists used by multiple onboarding screens.
+ * Base avatar items mapping avatar IDs to drawable resource IDs.
*/
val baseAvatarItems: List> = listOf(
"baby_boy" to R.drawable.family_member_baby,
@@ -426,14 +212,66 @@ object OnboardingChipData {
"tomato_avtar" to R.drawable.avtar_tomato
)
+ /**
+ * Avatar items for editing (same as baseAvatarItems).
+ */
val editAvatarItems: List> = baseAvatarItems
- /** Resolve avatar id to drawable resource id; null if not found. Shared by Host and screens. */
+ /**
+ * Resolve avatar id to drawable resource id; null if not found.
+ * Shared by Host and screens.
+ */
fun avatarResOrNull(avatarId: String): Int? =
baseAvatarItems.firstOrNull { (id, _) -> id == avatarId }?.second
/** Resolve chip id to display label for dietary preference sync; returns id if not found. */
- fun labelForChipId(chipId: String): String =
- (0..9).flatMap { chipsForStep(it) }.firstOrNull { it.id == chipId }?.label ?: chipId
+ fun labelForChipId(chipId: String): String {
+ val steps = dynamicSteps()
+ val maxStep = (steps?.size ?: 0) - 1
+ return (0..maxStep).flatMap { chipsForStep(it) }.firstOrNull { it.id == chipId }?.label ?: chipId
+ }
+
+ /** Step IDs in order from dynamic JSON (food-notes API). */
+ val foodNotesStepIds: List
+ get() = dynamicSteps()?.map { it.id } ?: emptyList()
+
+ /** Map step id to drawable for CapsuleStepperRow / section headers. */
+ fun iconResForStepId(stepId: String): Int = when (stepId) {
+ "allergies" -> R.drawable.ic_step_allergies
+ "intolerances" -> R.drawable.ic_step_intolerances
+ "healthConditions" -> R.drawable.ic_step_health_conditions
+ "lifeStage" -> R.drawable.ic_step_life_style
+ "region" -> R.drawable.ic_step_region
+ "avoid" -> R.drawable.ic_step_avoid_cross
+ "lifeStyle" -> R.drawable.ic_step_diet_preferences
+ "nutrition" -> R.drawable.ic_step_meals
+ "ethical" -> R.drawable.ic_step_ethical
+ "taste" -> R.drawable.iconoir_chocolate
+ else -> R.drawable.ic_step_allergies
+ }
+
+ /**
+ * Build food-notes API content from a set of selected chip IDs (for one member or Everyone).
+ * Matches iOS buildContentFromPreferences: step id -> list of { "name", "iconName" }.
+ */
+ fun buildFoodNotesContentFromChipIds(chipIds: Set): Map>> {
+ if (chipIds.isEmpty()) return emptyMap()
+ val content = mutableMapOf>>()
+ val stepIds = foodNotesStepIds
+ for (stepIndex in stepIds.indices) {
+ val stepId = stepIds.getOrNull(stepIndex) ?: continue
+ val chips = chipsForStep(stepIndex)
+ val selected = chips.filter { it.id in chipIds }.map { chip ->
+ mapOf(
+ "name" to chip.label,
+ "iconName" to (chip.iconPrefix.trim().ifEmpty { "" })
+ )
+ }
+ if (selected.isNotEmpty()) {
+ content[stepId] = selected.toMutableList()
+ }
+ }
+ return content
+ }
}
diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/model/OnboardingPersistence.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/model/OnboardingPersistence.kt
index 8b30e6a..472a3bf 100644
--- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/model/OnboardingPersistence.kt
+++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/model/OnboardingPersistence.kt
@@ -1,6 +1,7 @@
package lc.fungee.Ingredicheck.onboarding.model
import android.content.Context
+import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
@@ -29,6 +30,9 @@ class OnboardingPersistence(
private val KEY_ADD_FAMILY_GENERATED_AVATAR_URL = stringPreferencesKey("onboarding_add_family_generated_avatar_url")
private val KEY_MEMOJI_GENERATION_COMPLETED = booleanPreferencesKey("onboarding_memoji_generation_completed")
private val KEY_FAMILY_OVERVIEW_MEMBERS = stringPreferencesKey("onboarding_family_overview_members")
+ private val KEY_SELECTED_ALLERGIES_BY_MEMBER = stringPreferencesKey("onboarding_selected_allergies_by_member")
+ private val KEY_SELECTED_ALLERGY_MEMBER_ID = stringPreferencesKey("onboarding_selected_allergy_member_id")
+ private val KEY_ALLERGY_STEP_INDEX = stringPreferencesKey("onboarding_allergy_step_index")
}
data class SavedState(
@@ -125,4 +129,51 @@ class OnboardingPersistence(
prefs[KEY_FAMILY_OVERVIEW_MEMBERS] = Json.encodeToString(familyOverviewMembers)
}
}
+
+ /**
+ * Save allergy selections state (selected chips/cards per member and current step index).
+ */
+ suspend fun setAllergySelectionsState(
+ selectedAllergiesByMember: Map>,
+ selectedAllergyMemberId: String,
+ allergyStepIndex: Int
+ ) {
+ Log.d(
+ "OnboardingAllergies",
+ "[PERSIST] setAllergySelectionsState selections=$selectedAllergiesByMember " +
+ "selectedMember=$selectedAllergyMemberId stepIndex=$allergyStepIndex"
+ )
+ context.onboardingDataStore.edit { prefs ->
+ // Convert Map> to JSON
+ val selectionsJson = Json.encodeToString(
+ selectedAllergiesByMember.mapValues { it.value.toList() }
+ )
+ prefs[KEY_SELECTED_ALLERGIES_BY_MEMBER] = selectionsJson
+ prefs[KEY_SELECTED_ALLERGY_MEMBER_ID] = selectedAllergyMemberId
+ prefs[KEY_ALLERGY_STEP_INDEX] = allergyStepIndex.toString()
+ }
+ }
+
+ /**
+ * Get allergy selections state (selected chips/cards per member and current step index).
+ */
+ suspend fun getAllergySelectionsState(): Triple