From f51617e7ca23b12f837cb5de336f031bd2cc4df0 Mon Sep 17 00:00:00 2001 From: Gaurav-eightinity01 Date: Tue, 17 Feb 2026 14:30:54 +0530 Subject: [PATCH 01/18] Fix recomposition issue in `AddAllergiesSheet` Improved the reliability of allergy selection by ensuring `AddAllergiesSheet` correctly recomposes when chips are toggled or members are switched. Key changes: - Introduced `allergySelectionRevision` and `activeMemberSelections` states to explicitly track and force UI updates. - Wrapped `AddAllergiesSheet` in a `key` block to ensure recomposition when the active member, selection revision, or specific selections change. - Updated `onToggleAllergy` logic to perform immutable updates on `selectedAllergiesByMember` (SnapshotStateMap) to properly trigger state observers. - Added comprehensive logging to track member switching and chip toggle events. --- .../onboarding/ui/OnboardingHost.kt | 156 +++++++++++------- 1 file changed, 100 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt index 4e8f28e..74ff931 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt @@ -44,6 +44,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.key import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -861,8 +862,12 @@ fun OnboardingHost( mutableStateOf(vm.familyOverviewMembers.firstOrNull()?.id.orEmpty()) } val selectedAllergies = remember { mutableStateListOf() } - // memberKey (\"ALL\" or member.id) -> set of chipIds selected for that member + // memberKey ("ALL" or member.id) -> set of chipIds selected for that member val selectedAllergiesByMember = remember { mutableStateMapOf>() } + // Bump on every chip toggle so the sheet reliably recomposes (workaround for SnapshotStateMap). + var allergySelectionRevision by remember { mutableStateOf(0) } + // Explicitly track selections for the active member to force sheet recomposition + var activeMemberSelections by remember { mutableStateOf>(emptySet()) } // Progress tracking within the fine‑tune flow (allergies, intolerances, etc.) // These same steps drive both the CapsuleStepperRow and the AnimatedProgressLine. @@ -1178,70 +1183,109 @@ fun OnboardingHost( // (or Everyone) has chosen, not the union across all members. val activeMemberId = selectedAllergyMemberIdState.value val activeMemberKey = if (activeMemberId.isBlank()) "ALL" else activeMemberId - val selectedAllergiesForActiveMember: Set = - selectedAllergiesByMember[activeMemberKey]?.toSet() ?: emptySet() + + // Sync activeMemberSelections whenever activeMemberKey or revision changes + LaunchedEffect(activeMemberKey, allergySelectionRevision) { + val latest = selectedAllergiesByMember[activeMemberKey]?.toSet() ?: emptySet() + if (activeMemberSelections != latest) { + activeMemberSelections = latest + Log.d( + "OnboardingAllergies", + "[SYNC] activeMemberSelections updated to=$latest for memberKey=$activeMemberKey revision=$allergySelectionRevision" + ) + } + } Log.d( "OnboardingAllergies", - "Sheet for memberKey=$activeMemberKey " + - "selectedAllergiesForActiveMember=$selectedAllergiesForActiveMember " + - "selectedAllergiesByMember=$selectedAllergiesByMember" + "[SHEET RECOMPOSE] memberKey=$activeMemberKey " + + "activeMemberSelections=$activeMemberSelections " + + "revision=$allergySelectionRevision " + + "selectedAllergiesByMember.keys=${selectedAllergiesByMember.keys}" ) - AddAllergiesSheet( - members = vm.familyOverviewMembers.toList(), - selectedMemberId = selectedAllergyMemberIdState.value, - selectedAllergies = selectedAllergiesForActiveMember, - onMemberSelected = { selectedAllergyMemberIdState.value = it }, - onToggleAllergy = { allergyId -> - val activeMemberId = selectedAllergyMemberIdState.value - val memberKey = if (activeMemberId.isBlank()) "ALL" else activeMemberId - - Log.d( - "OnboardingAllergies", - "onToggleAllergy START chip=$allergyId memberKey=$memberKey " + - "beforeChipsForMember=${selectedAllergiesByMember[memberKey]} " + - "selectedAllergiesByMember=$selectedAllergiesByMember" - ) + // Key on revision (bumped every tap) so the sheet always recomposes when + // chips are toggled. Use activeMemberSelections which is explicitly updated. + key(activeMemberKey, allergySelectionRevision, activeMemberSelections.sorted().joinToString(",")) { + AddAllergiesSheet( + members = vm.familyOverviewMembers.toList(), + selectedMemberId = selectedAllergyMemberIdState.value, + selectedAllergies = activeMemberSelections, + onMemberSelected = { + val oldMemberKey = if (selectedAllergyMemberIdState.value.isBlank()) "ALL" else selectedAllergyMemberIdState.value + val newMemberKey = if (it.isBlank()) "ALL" else it + Log.d( + "OnboardingAllergies", + "[MEMBER SWITCH] from=$oldMemberKey to=$newMemberKey " + + "selectionsForNewMember=${selectedAllergiesByMember[newMemberKey]?.toSet()}" + ) + selectedAllergyMemberIdState.value = it + // Update activeMemberSelections immediately when switching members + activeMemberSelections = selectedAllergiesByMember[newMemberKey]?.toSet() ?: emptySet() + }, + onToggleAllergy = { allergyId -> + val activeMemberId = selectedAllergyMemberIdState.value + val memberKey = if (activeMemberId.isBlank()) "ALL" else activeMemberId + + Log.d( + "OnboardingAllergies", + "[TAP] START chip=$allergyId memberKey=$memberKey " + + "beforeChips=${selectedAllergiesByMember[memberKey]?.toSet()}" + ) - val chipsForMember = - selectedAllergiesByMember.getOrPut(memberKey) { mutableSetOf() } - if (chipsForMember.contains(allergyId)) { - chipsForMember.remove(allergyId) - if (chipsForMember.isEmpty()) { - selectedAllergiesByMember.remove(memberKey) + // Copy out, mutate, then write back so SnapshotStateMap sees a change + // and the sheet recomposes. Mutating the inner MutableSet in place + // does not trigger recomposition. + val chipsForMember = + (selectedAllergiesByMember[memberKey]?.toMutableSet() ?: mutableSetOf()) + if (chipsForMember.contains(allergyId)) { + chipsForMember.remove(allergyId) + if (chipsForMember.isEmpty()) { + selectedAllergiesByMember.remove(memberKey) + } else { + selectedAllergiesByMember[memberKey] = chipsForMember + } + } else { + chipsForMember.add(allergyId) + selectedAllergiesByMember[memberKey] = chipsForMember } - } else { - chipsForMember.add(allergyId) - } - // Rebuild the flat selectedAllergies list as the union of all chips - // selected by any member (used only for background capsules). - selectedAllergies.clear() - selectedAllergies.addAll( - selectedAllergiesByMember.values - .flatMap { it } - .toSet() - ) + // Rebuild the flat selectedAllergies list as the union of all chips + // selected by any member (used only for background capsules). + selectedAllergies.clear() + selectedAllergies.addAll( + selectedAllergiesByMember.values + .flatMap { it } + .toSet() + ) - Log.d( - "OnboardingAllergies", - "onToggleAllergy END chip=$allergyId memberKey=$memberKey " + - "afterChipsForMember=${selectedAllergiesByMember[memberKey]} " + - "selectedAllergiesByMember=$selectedAllergiesByMember " + - "selectedAllergies=$selectedAllergies" - ) - }, - onNext = { - // Advance to next fine‑tune step visually; once at the end, exit onboarding. - if (allergyStepIndex < allergySteps.lastIndex) { - allergyStepIndex++ - } else { - onExitOnboarding() - } - }, - questionStepIndex = allergyStepIndex - ) + // Immediately update activeMemberSelections if this is for the active member + // BEFORE incrementing revision so the key block sees the updated value + if (memberKey == activeMemberKey) { + activeMemberSelections = selectedAllergiesByMember[memberKey]?.toSet() ?: emptySet() + } + + allergySelectionRevision++ + + Log.d( + "OnboardingAllergies", + "[TAP] END chip=$allergyId memberKey=$memberKey " + + "afterChips=${selectedAllergiesByMember[memberKey]?.toSet()} " + + "revision=$allergySelectionRevision " + + "activeMemberSelections=$activeMemberSelections" + ) + }, + onNext = { + // Advance to next fine‑tune step visually; once at the end, exit onboarding. + if (allergyStepIndex < allergySteps.lastIndex) { + allergyStepIndex++ + } else { + onExitOnboarding() + } + }, + questionStepIndex = allergyStepIndex + ) + } } OnboardingStep.SIGN_IN_INITIAL -> { SignInInitialSheet( From d0ccae43a682f918ccb051e252523033c22fe149 Mon Sep 17 00:00:00 2001 From: Gaurav-eightinity01 Date: Tue, 17 Feb 2026 15:20:45 +0530 Subject: [PATCH 02/18] Update CapsuleStepperRow and add primaryChipEffect - Refactor `CapsuleStepperRow` to use dynamic width for the active capsule via `onGloballyPositioned`, ensuring the background line length matches the real UI. - Update `CapsuleStepperRow` styling including adjusted padding, spacing, and icon sizes. - Introduce `primaryChipEffect` modifier in `PrimaryButton.kt` to provide a gradient and shadow effect without an outer border, specifically for selected chips. - Update `AllergyScreens.kt` to use the new `primaryChipEffect` for selected onboarding chips. --- .../onboarding/ui/AllergyScreens.kt | 7 +- .../ui/components/CapsuleStepperRow.kt | 43 ++++++--- .../ui/components/buttons/PrimaryButton.kt | 92 +++++++++++++++++++ 3 files changed, 122 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt index fcb92a6..4b1075d 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt @@ -57,6 +57,7 @@ import lc.fungee.Ingredicheck.R import lc.fungee.Ingredicheck.onboarding.data.OnboardingChipData import lc.fungee.Ingredicheck.onboarding.model.OnboardingViewModel import lc.fungee.Ingredicheck.ui.components.buttons.primaryButtonEffect +import lc.fungee.Ingredicheck.ui.components.buttons.primaryChipEffect import lc.fungee.Ingredicheck.ui.theme.Greyscale10 import lc.fungee.Ingredicheck.ui.theme.Greyscale40 import lc.fungee.Ingredicheck.ui.theme.Greyscale70 @@ -360,11 +361,7 @@ private fun AllergyChip( ) { val shape = RoundedCornerShape(999.dp) val baseModifier = if (selected) { - Modifier.primaryButtonEffect( - isDisabled = false, - shape = shape, - disabledBackgroundColor = Greyscale40 - ) + Modifier.primaryChipEffect(shape) } else { Modifier.background(Color.White).border(1.dp, lc.fungee.Ingredicheck.ui.theme.Greyscale60, shape) } diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt index 67c853c..8acfd93 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -34,6 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -59,8 +61,8 @@ fun CapsuleStepperRow( inactiveColor: Color = Color(0xFFF6FCED), activeColor: Color = Color(0xFF91B640), lineHeight: Dp = 15.dp, - horizontalPadding: Dp = 20.dp, - itemSpacing: Dp = 12.dp, + horizontalPadding: Dp = 16.dp, + itemSpacing: Dp = 10.dp, animationDurationMs: Int = 280 ) { if (steps.isEmpty()) return @@ -77,7 +79,9 @@ fun CapsuleStepperRow( val collapsedWidth = 64.dp val expandedWidth = 180.dp - val itemWidth: (Int) -> Dp = { index -> if (index == clampedActive) expandedWidth else collapsedWidth } + // Track the actual measured width of the active capsule so the total line length + // matches the real UI instead of the old fixed 180.dp. + var activeItemWidth by remember { mutableStateOf(expandedWidth) } // Calculate fill width to the max reached index. // If the active item is before the max reached index, we must add the expansion delta @@ -99,10 +103,14 @@ fun CapsuleStepperRow( .heightIn(min = 64.dp) ) { val scrollState = rememberScrollState() - val density = LocalDensity.current val fillOverlap = lineHeight / 2 + val density = LocalDensity.current + + // Total width: all capsules at collapsed width + extra space contributed + // by the active capsule beyond the collapsed width, plus item spacing. + val extraWidthFromActive = (activeItemWidth - collapsedWidth).coerceAtLeast(0.dp) val totalContentWidth = - (collapsedWidth * (steps.size - 1)) + expandedWidth + (itemSpacing * (steps.size - 1)) + (collapsedWidth * steps.size) + extraWidthFromActive + (itemSpacing * (steps.size - 1)) // We use the direct calculated value instead of animated state for instant fill as requested previously, // or we can re-introduce animation if "fill fast" allows it. @@ -140,7 +148,8 @@ fun CapsuleStepperRow( .width(totalContentWidth) .height(lineHeight) .clip(RoundedCornerShape(lineHeight / 2)) - .background(inactiveColor) +// .background(Color.Green) + .background(inactiveColor) .align(Alignment.Companion.CenterStart) ) @@ -162,19 +171,23 @@ fun CapsuleStepperRow( val isActive = index == clampedActive val isVisited = index <= maxReachedIndex - val width by animateDpAsState( - targetValue = itemWidth(index), - animationSpec = tween(animationDurationMs), - label = "capsuleWidth" - ) - val bg = if (isVisited) activeColor else inactiveColor val iconTint = if (isVisited) Color.Companion.White else Color(0xFFC4E092) Row( modifier = Modifier.Companion .height(collapsedHeight) - .width(width) + // Inactive capsules keep a fixed width; active capsules wrap content. + .then( + if (isActive) { + Modifier.Companion.onGloballyPositioned { coordinates -> + val widthPx = coordinates.size.width + activeItemWidth = with(density) { widthPx.toDp() } + } + } else { + Modifier.Companion.width(collapsedWidth) + } + ) .clip(androidx.compose.foundation.shape.RoundedCornerShape(999.dp)) .background(bg) .then( @@ -200,11 +213,11 @@ fun CapsuleStepperRow( painter = painterResource(step.iconRes), contentDescription = null, tint = iconTint, - modifier = Modifier.Companion.size(18.dp) + modifier = Modifier.Companion.size(24.dp) ) } - Spacer(modifier = Modifier.Companion.width(10.dp)) + Spacer(modifier = Modifier.Companion.width(4.dp)) Text( text = step.title, fontFamily = Nunito, diff --git a/app/src/main/java/lc/fungee/Ingredicheck/ui/components/buttons/PrimaryButton.kt b/app/src/main/java/lc/fungee/Ingredicheck/ui/components/buttons/PrimaryButton.kt index cbac717..0097ef5 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/ui/components/buttons/PrimaryButton.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/ui/components/buttons/PrimaryButton.kt @@ -242,6 +242,98 @@ fun Modifier.primaryButtonEffect( } } +/** + * Primary-style pill effect without the outer white border. + * + * Used for selected allergy chips so they visually match the primary + * button (gradient, shadows) but without the 1px white stroke. + */ +fun Modifier.primaryChipEffect( + shape: RoundedCornerShape +): Modifier = this.drawBehind { + val size = size + val outline = shape.createOutline(size, layoutDirection, this) + + // 1. Drop Shadow (same as primaryButtonEffect) + drawIntoCanvas { canvas -> + val paint = Paint().asFrameworkPaint().apply { + color = android.graphics.Color.TRANSPARENT + setShadowLayer( + 11.dp.toPx(), + 0.dp.toPx(), + 4.dp.toPx(), + android.graphics.Color.argb((0.57f * 255).toInt(), 197, 197, 197) + ) + } + canvas.nativeCanvas.drawRoundRect( + 0f, 0f, size.width, size.height, + size.height / 2, size.height / 2, + paint + ) + } + + // 2. Background Gradient (same as primaryButtonEffect) + val brush = Brush.linearGradient( + colors = listOf(Color(0xFF9DCF10), Color(0xFF6B8E06)), + start = Offset(0f, 0f), + end = Offset(size.width, size.height) + ) + drawOutline(outline, brush) + + // 3. Inner Shadows (same as primaryButtonEffect, but we intentionally + // skip the final white border so the chip has a clean edge) + drawIntoCanvas { canvas -> + val path = Path().apply { + addRoundRect( + RoundRect( + rect = Rect(0f, 0f, size.width, size.height), + cornerRadius = CornerRadius(size.height / 2) + ) + ) + } + canvas.save() + canvas.clipPath(path) + + // Inner Shadow 1 + val shadow1Paint = Paint().asFrameworkPaint().apply { + color = android.graphics.Color.TRANSPARENT + strokeWidth = 10.dp.toPx() + style = android.graphics.Paint.Style.STROKE + setShadowLayer( + 7.5.dp.toPx(), + 2.dp.toPx(), + 9.dp.toPx(), + android.graphics.Color.argb((0.25f * 255).toInt(), 237, 237, 237) + ) + } + canvas.nativeCanvas.drawRoundRect( + -5.dp.toPx(), -5.dp.toPx(), size.width + 5.dp.toPx(), size.height + 5.dp.toPx(), + (size.height / 2) + 5.dp.toPx(), (size.height / 2) + 5.dp.toPx(), + shadow1Paint + ) + + // Inner Shadow 2 + val shadow2Paint = Paint().asFrameworkPaint().apply { + color = android.graphics.Color.TRANSPARENT + strokeWidth = 10.dp.toPx() + style = android.graphics.Paint.Style.STROKE + setShadowLayer( + 5.7.dp.toPx(), + 0.dp.toPx(), + 4.dp.toPx(), + android.graphics.Color.parseColor("#72930A") + ) + } + canvas.nativeCanvas.drawRoundRect( + -5.dp.toPx(), -5.dp.toPx(), size.width + 5.dp.toPx(), size.height + 5.dp.toPx(), + (size.height / 2) + 5.dp.toPx(), (size.height / 2) + 5.dp.toPx(), + shadow2Paint + ) + + canvas.restore() + } +} + @Preview(showBackground = true) @Composable private fun PrimaryButtonPreview() { From 5f5f12b14b7c9327c020ad806d79f35541bd99ff Mon Sep 17 00:00:00 2001 From: Gaurav-eightinity01 Date: Tue, 17 Feb 2026 18:34:42 +0530 Subject: [PATCH 03/18] Add region and cultural food traditions selection to onboarding - Introduced `RegionDefinition` data class and a comprehensive list of regional food traditions (India, Africa, Middle East, East Asia, etc.) to `OnboardingChipData`. - Implemented `RegionSelectionSection` and `RegionSectionRow` in `AllergyScreens` to support expandable/collapsible region groups. - Added a `SimpleFlowRow` layout utility for flexible chip arrangement within region sections. - Updated `OnboardingHost` and `AllergyScreens` to use the new regional UI for step index 4, including a fixed-position navigation button and scrollable content area. - Refined UI spacing, font sizes, and progress line calculations for better alignment and consistency. - Added `@SuppressLint("SuspiciousIndentation")` to `OnboardingHost`. --- .../onboarding/data/OnboardingData.kt | 84 ++++- .../onboarding/ui/AllergyScreens.kt | 333 +++++++++++++++--- .../onboarding/ui/OnboardingHost.kt | 32 +- .../ui/components/AnimatedProgressLine.kt | 4 +- .../ui/components/CapsuleStepperRow.kt | 5 +- 5 files changed, 391 insertions(+), 67 deletions(-) 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 0f2a730..64ebf63 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 @@ -14,6 +14,19 @@ data class ChipDefinition( val iconPrefix: String ) +/** + * Region definition used for the cultural / regional food traditions + * step in the fine‑tune flow. + * + * Each region has a display name and a list of sub‑regions, which are + * modelled as regular chips (so they share the same styling and state + * handling as other chip-based steps). + */ +data class RegionDefinition( + val name: String, + val subRegions: List +) + object OnboardingChipData { /** @@ -29,8 +42,9 @@ object OnboardingChipData { "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 -> "Where does your IngrediFam draw its food traditions from?" to - "This helps us respect your family’s food traditions." + // 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." else -> "Does anyone in your IngrediFam have allergies we should know?" to @@ -98,6 +112,72 @@ object OnboardingChipData { } } + /** + * 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", "✏️ ") + ) + ) + ) + /** * Resolves a chip id to its definition (label + emoji) from any step. * Used to display selected chips in the CapsuleSkeletonBox. diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt index 4b1075d..267e5e6 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt @@ -24,17 +24,24 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -55,6 +62,7 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import lc.fungee.Ingredicheck.R import lc.fungee.Ingredicheck.onboarding.data.OnboardingChipData +import lc.fungee.Ingredicheck.onboarding.data.RegionDefinition import lc.fungee.Ingredicheck.onboarding.model.OnboardingViewModel import lc.fungee.Ingredicheck.ui.components.buttons.primaryButtonEffect import lc.fungee.Ingredicheck.ui.components.buttons.primaryChipEffect @@ -293,60 +301,120 @@ internal fun AddAllergiesSheet( } } - Spacer(modifier = Modifier.height(18.dp)) + Spacer(modifier = Modifier.height(10.dp)) - val allergies = remember(questionStepIndex) { - OnboardingChipData.chipsForStep(questionStepIndex) - } + if (questionStepIndex == 4) { + // Region-style grouped UI – mirrors iOS DynamicRegionsQuestionView. + // The list of regions scrolls, but the green arrow button stays fixed + // at the bottom-right of the sheet (does not scroll). + val scrollState = rememberScrollState() - Box( - modifier = Modifier - .fillMaxWidth() - .animateContentSize( - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 260.dp) + .clip(RoundedCornerShape(22.dp)) + .background(Color.White) + ) { + // Scrollable content: region capsules + their inner chips + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(horizontal = 20.dp, vertical = 0.dp) + + + + ) { + RegionSelectionSection( + selectedAllergies = selectedAllergies, + onToggleAllergy = onToggleAllergy ) - ) - .clip(RoundedCornerShape(22.dp)) - .background(Color.White) - .padding(horizontal = 20.dp) - ) { - Column { - FlowRowWithRightAlignedButton( - modifier = Modifier.fillMaxWidth(), - horizontalSpacing = 8.dp, - verticalSpacing = 8.dp + + // Extra bottom spacer so last row isn't hidden behind the arrow button + Spacer(modifier = Modifier.height(10.dp)) + } + + // Fixed-position primary arrow button (does NOT scroll) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 16.dp) + .size(56.dp) + .primaryButtonEffect( + isDisabled = false, + shape = RoundedCornerShape(percent = 50), + disabledBackgroundColor = Greyscale40 + ) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onNext() }, + contentAlignment = Alignment.Center ) { - allergies.forEach { def -> - val isSelected = selectedAllergies.contains(def.id) - AllergyChip( - label = def.iconPrefix + def.label, - selected = isSelected, - onClick = { onToggleAllergy(def.id) } + Icon( + imageVector = Icons.Filled.ArrowForward, + contentDescription = null, + tint = Greyscale10, + modifier = Modifier.size(24.dp) + ) + } + } + } else { + val allergies = remember(questionStepIndex) { + OnboardingChipData.chipsForStep(questionStepIndex) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow ) - } - Box( - modifier = Modifier - .size(56.dp) - .primaryButtonEffect( - isDisabled = false, - shape = RoundedCornerShape(percent = 50), - disabledBackgroundColor = Greyscale40 - ) - .clip(CircleShape) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { onNext() }, - contentAlignment = Alignment.Center + ) + .clip(RoundedCornerShape(22.dp)) + .background(Color.White) + .padding(horizontal = 20.dp) + ) { + Column { + FlowRowWithRightAlignedButton( + modifier = Modifier.fillMaxWidth(), + horizontalSpacing = 8.dp, + verticalSpacing = 8.dp ) { - Icon( - imageVector = Icons.Filled.ArrowForward, - contentDescription = null, - tint = Greyscale10, - modifier = Modifier.size(24.dp) - ) + allergies.forEach { def -> + val isSelected = selectedAllergies.contains(def.id) + AllergyChip( + label = def.iconPrefix + def.label, + selected = isSelected, + onClick = { onToggleAllergy(def.id) } + ) + } + Box( + modifier = Modifier + .size(56.dp) + .primaryButtonEffect( + isDisabled = false, + shape = RoundedCornerShape(percent = 50), + disabledBackgroundColor = Greyscale40 + ) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onNext() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ArrowForward, + contentDescription = null, + tint = Greyscale10, + modifier = Modifier.size(24.dp) + ) + } } } } @@ -380,13 +448,180 @@ private fun AllergyChip( text = label, fontFamily = Manrope, fontWeight = FontWeight.Medium, - fontSize = 16.sp, + fontSize = 14.sp, color = if (selected) Greyscale10 else Greyscale150, maxLines = 1 ) } } +@Composable +private fun RegionSelectionSection( + selectedAllergies: Set, + onToggleAllergy: (String) -> Unit +) { + val regions = remember { OnboardingChipData.regions } + // Track which regions are expanded (by name). Mirrors the iOS expandedSectionIds set. + var expandedRegionNames by remember { mutableStateOf>(emptySet()) } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + regions.forEach { region -> + // If a region only has a single sub-region, skip the expandable + // header and surface the chip directly – same as iOS. + if (region.subRegions.size == 1) { + SimpleFlowRow( + horizontalSpacing = 8.dp, + verticalSpacing = 8.dp + ) { + val def = region.subRegions.first() + val isSelected = selectedAllergies.contains(def.id) + AllergyChip( + label = def.iconPrefix + def.label, + selected = isSelected, + onClick = { onToggleAllergy(def.id) } + ) + } + } else { + val hasAnySelection = region.subRegions.any { selectedAllergies.contains(it.id) } + val isExpanded = expandedRegionNames.contains(region.name) + RegionSectionRow( + region = region, + isSectionSelected = hasAnySelection, + isExpanded = isExpanded, + selectedAllergies = selectedAllergies, + onToggleExpanded = { + expandedRegionNames = + if (isExpanded) expandedRegionNames - region.name + else expandedRegionNames + region.name + }, + onToggleAllergy = onToggleAllergy + ) + } + } + } +} + +@Composable +private fun RegionSectionRow( + region: RegionDefinition, + isSectionSelected: Boolean, + isExpanded: Boolean, + selectedAllergies: Set, + onToggleExpanded: () -> Unit, + onToggleAllergy: (String) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + val shape = RoundedCornerShape(999.dp) + val headerModifier = + if (isSectionSelected || isExpanded) { + Modifier.primaryChipEffect(shape) + } else { + Modifier + .background(Color.White) + .border(1.dp, lc.fungee.Ingredicheck.ui.theme.Greyscale60, shape) + } + + Box( + modifier = Modifier + .then(headerModifier) + .clip(shape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onToggleExpanded() } + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = region.name, + fontFamily = Manrope, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = if (isSectionSelected || isExpanded) Greyscale10 else Greyscale150, + modifier = Modifier.weight(1f, fill = false) + ) + Icon( + imageVector = if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = if (isSectionSelected || isExpanded) Greyscale10 else Greyscale120, + modifier = Modifier.size(18.dp) + ) + } + } + + if (isExpanded) { + Spacer(modifier = Modifier.height(8.dp)) + SimpleFlowRow( + horizontalSpacing = 8.dp, + verticalSpacing = 8.dp + ) { + region.subRegions.forEach { def -> + val isSelected = selectedAllergies.contains(def.id) + AllergyChip( + label = def.iconPrefix + def.label, + selected = isSelected, + onClick = { onToggleAllergy(def.id) } + ) + } + } + } + } +} + +@Composable +private fun SimpleFlowRow( + modifier: Modifier = Modifier, + horizontalSpacing: Dp, + verticalSpacing: Dp, + content: @Composable () -> Unit +) { + Layout(content = content, modifier = modifier) { measurables, constraints -> + val spacingX = horizontalSpacing.roundToPx() + val spacingY = verticalSpacing.roundToPx() + + if (measurables.isEmpty()) { + return@Layout layout(0, 0) {} + } + + val placeables = measurables.map { measurable -> + measurable.measure(constraints.copy(minWidth = 0, minHeight = 0)) + } + + val maxWidth = constraints.maxWidth + var x = 0 + var y = 0 + var rowHeight = 0 + val positions = ArrayList(placeables.size) + + placeables.forEach { p -> + if (x > 0 && x + p.width > maxWidth) { + x = 0 + y += rowHeight + spacingY + rowHeight = 0 + } + positions.add(intArrayOf(x, y)) + x += p.width + spacingX + rowHeight = maxOf(rowHeight, p.height) + } + + val totalHeight = (y + rowHeight).coerceIn(constraints.minHeight, constraints.maxHeight) + + layout(width = maxWidth, height = totalHeight) { + placeables.forEachIndexed { index, placeable -> + val pos = positions[index] + placeable.placeRelative(pos[0], pos[1]) + } + } + } +} + @Composable private fun FlowRowWithRightAlignedButton( modifier: Modifier = Modifier, diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt index 74ff931..2edfd7f 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt @@ -1,5 +1,6 @@ package lc.fungee.Ingredicheck.onboarding.ui +import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.content.Context @@ -767,6 +768,7 @@ private fun FamilyOverviewBackground( } } +@SuppressLint("SuspiciousIndentation") @Composable fun OnboardingHost( authViewModel: AuthViewModel, @@ -1018,7 +1020,7 @@ fun OnboardingHost( Column( modifier = Modifier.fillMaxSize() ) { - Spacer(modifier = Modifier.height(48.dp)) + Spacer(modifier = Modifier.height(40.dp)) // Animate progress based on current allergyStepIndex. val rawProgress = @@ -1030,21 +1032,23 @@ fun OnboardingHost( label = "allergyProgress" ) - AnimatedProgressLine( - progress = animatedProgress, - modifier = Modifier.padding(horizontal = 20.dp) - ) - Spacer(modifier = Modifier.height(10.dp)) + AnimatedProgressLine( + progress = animatedProgress, + modifier = Modifier.padding(horizontal = 20.dp) + ) +// Spacer(modifier = Modifier.height(10.dp)) + + CapsuleStepperRow( + steps = allergySteps, + activeIndex = allergyStepIndex, + onStepClick = { clickedIndex -> + allergyStepIndex = + clickedIndex.coerceIn(0, allergySteps.lastIndex) + } + ) - CapsuleStepperRow( - steps = allergySteps, - activeIndex = allergyStepIndex, - onStepClick = { clickedIndex -> - allergyStepIndex = clickedIndex.coerceIn(0, allergySteps.lastIndex) - } - ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(10.dp)) // Show a scrollable list of CapsuleSkeletonBox cards. // Steps 0-3 correspond to: Allergies, Intolerances, Health Conditions, Life Stage. diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/AnimatedProgressLine.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/AnimatedProgressLine.kt index 0ffee7b..6e3bc9c 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/AnimatedProgressLine.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/AnimatedProgressLine.kt @@ -78,7 +78,9 @@ fun AnimatedProgressLine( modifier = modifier .fillMaxWidth() // .padding(horizontal = 20.dp) - .height(44.dp), + .height(44.dp) + + , ) { val maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() } val clampedProgress = progress.coerceIn(0f, 1f) diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt index 8acfd93..6a84492 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt @@ -87,7 +87,9 @@ fun CapsuleStepperRow( // If the active item is before the max reached index, we must add the expansion delta // because the active item (which is wider) pushes the subsequent items (up to max) further to the right. val baseFill = (collapsedWidth + itemSpacing) * maxReachedIndex - val expansionDelta = if (clampedActive < maxReachedIndex) (expandedWidth - collapsedWidth) else 0.dp + // Use the *measured* active capsule width instead of the old fixed expandedWidth, + // so the filled line stops exactly before the first *unvisited* capsule. + val expansionDelta = if (clampedActive < maxReachedIndex) (activeItemWidth - collapsedWidth) else 0.dp val fillToStartOfActive = baseFill + expansionDelta val fillToStartOfActiveState by animateDpAsState( @@ -99,6 +101,7 @@ fun CapsuleStepperRow( Box( modifier = modifier .fillMaxWidth() + // .padding(horizontal = horizontalPadding) .heightIn(min = 64.dp) ) { From 2afe0d624444e85adadda1dc607301efef9f79a6 Mon Sep 17 00:00:00 2001 From: Gaurav-eightinity01 Date: Wed, 18 Feb 2026 18:33:11 +0530 Subject: [PATCH 04/18] Add StackedCardsComponent and update onboarding flow Introduced `StackedCardsComponent`, a swipeable card stack UI with smooth rotation and drag animations. This component is integrated into the onboarding flow for the "Avoid" step, allowing users to navigate through categorized dietary restrictions (Oils & Fats, Animal-Based, etc.) via card swipes. Key changes: - Created `StackedCardsComponent` with support for horizontal dragging, exit animations, and "stack" visual effects. - Added `AvoidOptionChip` and `SimpleFlowRow` to support the new card-based selection UI. - Expanded `OnboardingChipData` to include `avoidCards` definitions and additional chip categories for ethical and taste preferences. - Updated `AllergyScreens` to utilize the new stacked cards UI for the specific "Avoid" question step. - Added a new vector resource `leaf_arrow_circlepath.xml` used as a background element in the cards. - Removed the legacy `StackCards.kt` implementation. --- .idea/deploymentTargetSelector.xml | 18 +- .../onboarding/data/OnboardingData.kt | 112 ++++- .../onboarding/ui/AllergyScreens.kt | 158 ++++++- .../onboarding/ui/StackedCardsComponent.kt | 365 +++++++++++++++ .../onboarding/ui/components/StackCards.kt | 437 ------------------ .../ui/components/StackedCardsComponent.kt | 347 ++++++++++++++ .../res/drawable/leaf_arrow_circlepath.xml | 11 + 7 files changed, 1001 insertions(+), 447 deletions(-) create mode 100644 app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/StackedCardsComponent.kt delete mode 100644 app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackCards.kt create mode 100644 app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackedCardsComponent.kt create mode 100644 app/src/main/res/drawable/leaf_arrow_circlepath.xml diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 83ea4eb..62599a1 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,7 +4,7 @@ - + + + + + - + - + 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 64ebf63..508dae6 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 @@ -27,6 +27,20 @@ data class RegionDefinition( val subRegions: List ) +data class AvoidOptionDefinition( + val id: String, + val label: String, + val iconPrefix: String +) + +data class AvoidCardDefinition( + val id: String, + val title: String, + val description: String, + val colorHex: String, + val options: List +) + object OnboardingChipData { /** @@ -56,7 +70,7 @@ object OnboardingChipData { * Returns the chip set (id, label, emoji prefix) for the given fine‑tune step. */ fun chipsForStep(step: Int): List { - return when (step.coerceIn(0, 5)) { + return when (step.coerceIn(0, 9)) { // 0 – Allergies 0 -> listOf( ChipDefinition("peanuts", "Peanuts", "🥜 "), @@ -108,10 +122,104 @@ object OnboardingChipData { ChipDefinition("none_lifestage", "None of these apply", "✅ ") ) + // 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) } } + // 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", "🧄 ") + ) + ) + ) + /** * Static definition of cultural / regional food traditions used on the * "Where does your IngrediFam draw its food traditions from?" step. @@ -183,7 +291,7 @@ object OnboardingChipData { * Used to display selected chips in the CapsuleSkeletonBox. */ fun chipForId(id: String): ChipDefinition? { - for (step in 0..5) { + for (step in 0..9) { chipsForStep(step).find { it.id == id }?.let { return it } } return null diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt index 267e5e6..9c973c9 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -44,14 +45,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -63,7 +67,9 @@ import coil.compose.AsyncImage import lc.fungee.Ingredicheck.R import lc.fungee.Ingredicheck.onboarding.data.OnboardingChipData import lc.fungee.Ingredicheck.onboarding.data.RegionDefinition +import lc.fungee.Ingredicheck.onboarding.data.AvoidOptionDefinition import lc.fungee.Ingredicheck.onboarding.model.OnboardingViewModel +import lc.fungee.Ingredicheck.onboarding.ui.components.StackedCardsComponent import lc.fungee.Ingredicheck.ui.components.buttons.primaryButtonEffect import lc.fungee.Ingredicheck.ui.components.buttons.primaryChipEffect import lc.fungee.Ingredicheck.ui.theme.Greyscale10 @@ -71,8 +77,10 @@ import lc.fungee.Ingredicheck.ui.theme.Greyscale40 import lc.fungee.Ingredicheck.ui.theme.Greyscale70 import lc.fungee.Ingredicheck.ui.theme.Greyscale100 import lc.fungee.Ingredicheck.ui.theme.Greyscale120 +import lc.fungee.Ingredicheck.ui.theme.Greyscale140 import lc.fungee.Ingredicheck.ui.theme.Greyscale150 import lc.fungee.Ingredicheck.ui.theme.Manrope +import lc.fungee.Ingredicheck.ui.theme.Nunito import lc.fungee.Ingredicheck.ui.theme.Primary700 @Composable @@ -301,7 +309,7 @@ internal fun AddAllergiesSheet( } } - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(20.dp)) if (questionStepIndex == 4) { // Region-style grouped UI – mirrors iOS DynamicRegionsQuestionView. @@ -361,6 +369,105 @@ internal fun AddAllergiesSheet( ) } } + } else if (questionStepIndex == 5) { + // Avoid step: show stacked cards instead of chips, and + // do NOT render the forward arrow button here. + val avoidCards = OnboardingChipData.avoidCards + val totalCards = avoidCards.size + + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + ) { + StackedCardsComponent( + modifier = Modifier, + cardContent = { index, isTop -> + val card = avoidCards[index % avoidCards.size] + + val bgColor = try { + Color(android.graphics.Color.parseColor(card.colorHex)) + } catch (_: IllegalArgumentException) { + Color(0xFFFFF6B3) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(270.dp) + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(24.dp), + spotColor = Color.Black.copy(alpha = 0.15f) + ) + .clip(RoundedCornerShape(24.dp)) + .background(bgColor) + .padding(horizontal = 12.dp, vertical = 16.dp) + ) { + if (isTop) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = card.title, + fontFamily = Nunito, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Greyscale150 + ) + Text( + text = "${index + 1}/$totalCards", + fontFamily = Manrope, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + color = Greyscale140 + ) + } + + Text( + text = card.description, + fontFamily = Manrope, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + color = Greyscale140 + ) + + Spacer(modifier = Modifier.height(6.dp)) + + SimpleFlowRow( + horizontalSpacing = 8.dp, + verticalSpacing = 8.dp + ) { + card.options.forEach { opt -> + val isSelected = selectedAllergies.contains(opt.id) + AvoidOptionChip( + option = opt, + isSelected = isSelected, + onClick = { onToggleAllergy(opt.id) } + ) + } + } + } + } + + Image( + painter = painterResource(id = R.drawable.leaf_arrow_circlepath), + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomEnd) + .size(100.dp), + contentScale = ContentScale.Fit + ) + } + } + ) + } } else { val allergies = remember(questionStepIndex) { OnboardingChipData.chipsForStep(questionStepIndex) @@ -455,6 +562,53 @@ private fun AllergyChip( } } +@Composable +fun AvoidOptionChip( + option: AvoidOptionDefinition, + isSelected: Boolean, + onClick: () -> Unit +) { + val shape = RoundedCornerShape(999.dp) + + val baseModifier = + if (isSelected) { + Modifier.primaryChipEffect(shape) + } else { + Modifier + .background(Color.White, shape) + .border(1.dp, Greyscale40, shape) + } + + Box( + modifier = Modifier + .then(baseModifier) + .clip(shape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + .padding(horizontal = 14.dp, vertical = 8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = option.iconPrefix, + fontSize = 16.sp + ) + Text( + text = option.label, + fontFamily = Manrope, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + color = if (isSelected) Greyscale10 else Color(0xFF303030), + maxLines = 1 + ) + } + } +} + @Composable private fun RegionSelectionSection( selectedAllergies: Set, @@ -576,7 +730,7 @@ private fun RegionSectionRow( } @Composable -private fun SimpleFlowRow( +fun SimpleFlowRow( modifier: Modifier = Modifier, horizontalSpacing: Dp, verticalSpacing: Dp, diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/StackedCardsComponent.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/StackedCardsComponent.kt new file mode 100644 index 0000000..c3e78f8 --- /dev/null +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/StackedCardsComponent.kt @@ -0,0 +1,365 @@ +package lc.fungee.Ingredicheck.onboarding.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.launch +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times +import lc.fungee.Ingredicheck.R +import lc.fungee.Ingredicheck.onboarding.data.OnboardingChipData +import kotlin.math.absoluteValue +import kotlin.math.roundToInt +import lc.fungee.Ingredicheck.ui.theme.Greyscale10 +import lc.fungee.Ingredicheck.ui.theme.Greyscale140 +import lc.fungee.Ingredicheck.ui.theme.Greyscale150 +import lc.fungee.Ingredicheck.ui.theme.Manrope +import lc.fungee.Ingredicheck.ui.theme.Nunito + +/** + * A composable that displays a stack of cards with swipe-to-rotate functionality. + * The second card (from top) is rotated by 6 degrees to show the stack effect. + * When a card is swiped left, it moves to the bottom and the next card becomes the top. + */ +@Composable +fun StackedCardsComponent( + modifier: Modifier = Modifier, + cardContent: @Composable (index: Int, isTop: Boolean) -> Unit +) { + // Create 6 cards with initial indices + val initialCards = remember { + mutableStateListOf(0, 1, 2, 3, 4, 5) + } + val cards = remember { initialCards } + + // Track drag offset for the top card + val dragOffsetX = remember { Animatable(0f) } + val dragOffsetY = remember { Animatable(0f) } + val isDragging = remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + val density = LocalDensity.current + val swipeThreshold = with(density) { 200.dp.toPx() } // Fallback absolute threshold + + // Track card width so we can compute 20% drag distance. + var cardWidth by remember { mutableStateOf(0f) } + val thirtyPercentThreshold = cardWidth * 0.2f // 20% of card width + + // Reset drag offset immediately when cards are reordered + // Use the top card index as a key to detect when it changes + val topCardIndex = remember(cards.size) { cards.lastOrNull() ?: -1 } + + LaunchedEffect(topCardIndex) { + // When top card changes, reset drag offset immediately + dragOffsetX.snapTo(0f) + dragOffsetY.snapTo(0f) + } + + val containerPadding = with(density) { 20.dp.toPx() } + + Box( + modifier = modifier + .fillMaxWidth() + .height(260.dp) + .padding(horizontal = 20.dp) + ) { + // Render cards from bottom to top (so top card is rendered last and appears on top) + cards.forEachIndexed { stackIndex, cardIndex -> + val isTopCard = stackIndex == cards.lastIndex + val isSecondCard = stackIndex == cards.lastIndex - 1 + + // Calculate z-index offset (cards behind are slightly offset) + val zIndexOffset = (cards.lastIndex - stackIndex) * 2.dp + + // Rotation: + // - Top card should be 0° + // - Second card (just behind top) should rest at 6° + // and animate smoothly back to 0° when it becomes the top card. + val targetRotation = when { + isTopCard -> 0f + isSecondCard -> 6f + else -> 0f + } + val rotation by animateFloatAsState( + targetValue = targetRotation, + animationSpec = tween( + durationMillis = 1800, // extra slow, very smooth glide 6° -> 0° + easing = FastOutSlowInEasing + ), + label = "stackCardRotation" + ) + + // Alpha: only show top 2 cards (top card and second card), hide the rest + val alpha = if (stackIndex >= cards.lastIndex - 1) 1f else 0f + + // Scale: only top 2 cards are visible, so no scaling needed + val scale = 1f + + Box( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + .offset(x = zIndexOffset, y = zIndexOffset) + .alpha(alpha) + .rotate(rotation) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + // Back card visual treatment: subtle blur layer like iOS + .let { base -> + if (isSecondCard && !isTopCard) { + base.blur( + radius = 4.dp, + edgeTreatment = BlurredEdgeTreatment.Unbounded + ) + } else { + base + } + } + .then( + if (isTopCard) { + Modifier + .onGloballyPositioned { coordinates -> + cardWidth = coordinates.size.width.toFloat() + } + .offset { + IntOffset( + dragOffsetX.value.roundToInt(), + dragOffsetY.value.roundToInt() + ) + } + .pointerInput(cardIndex) { + detectDragGestures( + onDragStart = { isDragging.value = true }, + onDragEnd = { + isDragging.value = false + val currentOffsetX = dragOffsetX.value + // Use 30% of card width when known, otherwise fall back to a + // fixed px threshold; support BOTH directions (left & right). + val baseThreshold = + if (cardWidth > 0f) thirtyPercentThreshold + else swipeThreshold + + // If swiped beyond threshold to LEFT or RIGHT, complete swipe + // with a slower, smooth animation so the user can see the card + // travelling off-screen. + if (currentOffsetX <= -baseThreshold || + currentOffsetX >= baseThreshold + ) { + coroutineScope.launch { + val width = if (cardWidth > 0f) cardWidth else swipeThreshold + val targetX = + if (currentOffsetX >= 0f) width * 1.4f + else -width * 1.4f + + // Animate card fully out of the screen + dragOffsetX.animateTo( + targetX, + animationSpec = tween( + durationMillis = 420, + easing = FastOutSlowInEasing + ) + ) + + // Reorder: move top card to bottom + val topCard = cards.removeAt(cards.lastIndex) + cards.add(0, topCard) + + // Reset offsets for the new top card + dragOffsetX.snapTo(0f) + dragOffsetY.snapTo(0f) + } + } else { + // Snap back if not swiped far enough + coroutineScope.launch { + dragOffsetX.animateTo( + 0f, + animationSpec = tween( + durationMillis = 280, + easing = FastOutSlowInEasing + ) + ) + dragOffsetY.animateTo( + 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + } + } + }, + onDrag = { change, dragAmount -> + change.consume() + coroutineScope.launch { + // Free horizontal dragging (primarily left); no clamping, + // so the card can visually reach / go past the screen edge. + val newOffsetX = dragOffsetX.value + dragAmount.x + dragOffsetX.snapTo(newOffsetX) + // Slight vertical offset for natural feel + dragOffsetY.snapTo(dragAmount.y * 0.3f) + } + } + ) + } + } else { + Modifier + } + ) + ) { + // Only the top card should reveal its full content (text, etc.). + // Cards behind can render a simplified/placeholder state if desired. + cardContent(cardIndex, isTopCard) + } + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5) +@Composable +private fun StackedCardsAvoidPreview() { + val cards = OnboardingChipData.avoidCards + val total = cards.size + + Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { + StackedCardsComponent( +// modifier = Modifier.padding(horizontal = 20.dp), + cardContent = { index, isTop -> + val card = cards[index % cards.size] + + val bgColor = try { + Color(android.graphics.Color.parseColor(card.colorHex)) + } catch (_: IllegalArgumentException) { + Color(0xFFFFF6B3) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(24.dp), + spotColor = Color.Black.copy(alpha = 0.15f) + ) + .clip(RoundedCornerShape(24.dp)) + .background(bgColor) + .padding(horizontal = 12.dp, vertical = 14.dp) + ) { + if (isTop) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = card.title, + fontFamily = Nunito, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Greyscale150 + ) + Text( + text = "${index + 1}/$total", + fontFamily = Manrope, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + color = Greyscale140 + ) + } + + Text( + text = card.description, + fontFamily = Manrope, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + color = Greyscale140 + ) + + Spacer(modifier = Modifier.height(6.dp)) + + SimpleFlowRow( + horizontalSpacing = 8.dp, + verticalSpacing = 8.dp + ) { + card.options.forEach { opt -> + AvoidOptionChip( + option = opt, + isSelected = false, + onClick = {} + ) + } + } + } + } + + Image( + painter = painterResource(id = R.drawable.leaf_arrow_circlepath), + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomEnd) + .height(56.dp) + .alpha(0.22f), + contentScale = ContentScale.Fit + ) + } + } + ) + } +} diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackCards.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackCards.kt deleted file mode 100644 index 35d8dc7..0000000 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackCards.kt +++ /dev/null @@ -1,437 +0,0 @@ -package lc.fungee.Ingredicheck.onboarding.ui.components - -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.BlurredEdgeTreatment -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import lc.fungee.Ingredicheck.ui.theme.Greyscale140 -import lc.fungee.Ingredicheck.ui.theme.Greyscale40 -import lc.fungee.Ingredicheck.ui.theme.Nunito - -data class StackCardChip( - val id: String, - val name: String, - val icon: String? = null -) - -data class StackCard( - val id: String, - val title: String, - val subTitle: String, - val color: Color, - val chips: List -) - -@Composable -fun StackCards( - cards: List, - isChipSelected: (StackCard, StackCardChip) -> Boolean = { _, _ -> false }, - onChipTap: (StackCard, StackCardChip) -> Unit = { _, _ -> }, - onSwipe: (() -> Unit)? = null, - modifier: Modifier = Modifier.Companion -) { - val stateCards = remember(cards) { mutableStateListOf().apply { addAll(cards) } } - val totalCardCount = stateCards.size - - var currentIndex by remember(stateCards) { - mutableIntStateOf(if (stateCards.isEmpty()) 0 else 1) - } - - val progressText by remember(currentIndex, totalCardCount) { - derivedStateOf { if (totalCardCount > 0) "$currentIndex/$totalCardCount" else "" } - } - - var dragX by remember { mutableStateOf(0f) } - val scope = rememberCoroutineScope() - - fun cycleCard() { - if (stateCards.isEmpty()) return - val first = stateCards.removeAt(0) - stateCards.add(first) - if (totalCardCount > 0) { - currentIndex = (currentIndex % totalCardCount) + 1 - } - } - - suspend fun animateSwipe(toX: Float) { - animate( - initialValue = dragX, - targetValue = toX, - animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing) - ) { value, _ -> - dragX = value - } - cycleCard() - dragX = 0f - onSwipe?.invoke() - } - - BoxWithConstraints(modifier = modifier) { - val cardHeight: Dp = maxHeight * 0.33f - val shape = RoundedCornerShape(24.dp) - val topCard = stateCards.getOrNull(0) - val backCard = stateCards.getOrNull(1) - val maxWidthPx = constraints.maxWidth.toFloat().coerceAtLeast(1f) - - Box { - if (backCard != null) { - StackCardSurface( - card = backCard, - progressText = progressText, - showContent = false, - shape = shape, - height = cardHeight, - isChipSelected = isChipSelected, - onChipTap = onChipTap, - modifier = Modifier.Companion - .padding(horizontal = 8.dp, vertical = 4.dp) - .shadow(elevation = 2.dp, shape = shape) - .border(1.dp, Color.Companion.White.copy(alpha = 0.35f), shape) - .blur(4.dp, edgeTreatment = BlurredEdgeTreatment.Companion.Unbounded) - .alpha(0.52f) - .graphicsLayer { rotationZ = 4f } - ) - } - - if (topCard != null) { - val dragRotationZ = ((dragX / maxWidthPx) * 6f).coerceIn(-6f, 6f) - StackCardSurface( - card = topCard, - progressText = progressText, - showContent = true, - shape = shape, - height = cardHeight, - isChipSelected = isChipSelected, - onChipTap = onChipTap, - modifier = Modifier.Companion - .graphicsLayer { - translationX = dragX - rotationZ = dragRotationZ - } - .pointerInput(topCard.id) { - detectDragGestures( - onDrag = { change, dragAmount -> - change.consume() - dragX += dragAmount.x - }, - onDragEnd = { - scope.launch { - when { - dragX > 80f -> animateSwipe(600f) - dragX < -80f -> animateSwipe(-600f) - else -> { - animate( - initialValue = dragX, - targetValue = 0f, - animationSpec = tween( - durationMillis = 280, - easing = FastOutSlowInEasing - ) - ) { value, _ -> - dragX = value - } - } - } - } - } - ) - } - ) - } - } - } -} - -@Composable -private fun StackCardSurface( - card: StackCard, - progressText: String, - showContent: Boolean, - shape: RoundedCornerShape, - height: Dp, - isChipSelected: (StackCard, StackCardChip) -> Boolean, - onChipTap: (StackCard, StackCardChip) -> Unit, - modifier: Modifier = Modifier.Companion -) { - Surface( - modifier = modifier - .height(height), - color = card.color, - shape = shape - ) { - Box(modifier = Modifier.Companion.fillMaxSize()) { - if (showContent) { - Box( - modifier = Modifier.Companion - .align(Alignment.Companion.BottomEnd) - .padding(end = 10.dp) - .offset(y = 17.dp) - .alpha(0.5f) - ) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = null, - tint = Color.Companion.White, - modifier = Modifier.Companion.size(76.dp) - ) - } - } - - Box( - modifier = Modifier.Companion - .fillMaxSize() - .padding(horizontal = 12.dp, vertical = 20.dp) - ) { - if (showContent) { - Box( - modifier = Modifier.Companion - .align(Alignment.Companion.TopEnd) - ) { - Text( - text = progressText, - fontFamily = Nunito, - fontSize = 14.sp, - color = Greyscale140 - ) - } - - Box(modifier = Modifier.Companion.align(Alignment.Companion.TopStart)) { - Text( - text = card.title, - fontSize = 20.sp, - fontWeight = FontWeight.Companion.Normal, - color = Color.Companion.Black - ) - } - - Text( - text = card.subTitle, - modifier = Modifier.Companion - .align(Alignment.Companion.TopStart) - .padding(top = 28.dp) - .alpha(0.8f), - fontSize = 12.sp, - fontWeight = FontWeight.Companion.Normal, - color = Color.Companion.Black - ) - - FlowLayout( - horizontalSpacing = 4.dp, - verticalSpacing = 8.dp, - modifier = Modifier.Companion - .align(Alignment.Companion.TopStart) - .padding(top = 72.dp) - ) { - card.chips.forEach { chip -> - StackCardChipPill( - title = chip.name, - icon = chip.icon, - isSelected = isChipSelected(card, chip), - onClick = { onChipTap(card, chip) } - ) - } - } - } else { - Text( - text = progressText, - modifier = Modifier.Companion - .align(Alignment.Companion.TopEnd) - .alpha(0f), - fontFamily = Nunito, - fontSize = 14.sp, - color = Greyscale140 - ) - } - } - } - } -} - -@Composable -private fun StackCardChipPill( - title: String, - icon: String?, - isSelected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier.Companion -) { - val shape = RoundedCornerShape(percent = 50) - val bg = if (isSelected) Color(0xFFF2F2F2) else Color.Companion.White - Row( - modifier = modifier - .clip(shape) - .background(bg, shape) - .border(1.dp, Greyscale40, shape) - .clickable { onClick() } - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.Companion.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - if (!icon.isNullOrBlank()) { - Text(text = icon, fontSize = 16.sp) - Spacer(modifier = Modifier.Companion.width(8.dp)) - } - Text( - text = title, - fontFamily = Nunito, - fontSize = 16.sp, - fontWeight = FontWeight.Companion.Normal, - color = Color(0xFF303030) - ) - } -} - -@Composable -private fun FlowLayout( - horizontalSpacing: Dp, - verticalSpacing: Dp, - modifier: Modifier = Modifier.Companion, - content: @Composable () -> Unit -) { - Layout( - content = content, - modifier = modifier - ) { measurables, constraints -> - val maxWidth = constraints.maxWidth - val hSpace = horizontalSpacing.roundToPx() - val vSpace = verticalSpacing.roundToPx() - - val placeables = - measurables.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) } - - var x = 0 - var y = 0 - var rowHeight = 0 - val positions = ArrayList>(placeables.size) - - placeables.forEach { p -> - if (x > 0 && x + p.width > maxWidth) { - x = 0 - y += rowHeight + vSpace - rowHeight = 0 - } - positions.add(x to y) - x += p.width + hSpace - rowHeight = maxOf(rowHeight, p.height) - } - - val height = (y + rowHeight).coerceIn(constraints.minHeight, constraints.maxHeight) - layout(width = maxWidth, height = height) { - placeables.forEachIndexed { index, p -> - val (px, py) = positions[index] - p.placeRelative(px, py) - } - } - } -} - -@Preview(showBackground = true, showSystemUi = true, device = Devices.PIXEL_4A) -@Composable -private fun StackCardsPreview() { - val sample = listOf( - StackCard( - id = "1", - title = "one", - subTitle = "This is the dummy sub-title, and this is the first text", - color = Color(0xFFB04BFF), - chips = listOf( - StackCardChip("1", "High Protein", "🍗"), - StackCardChip("2", "Low Carb", "🥒"), - StackCardChip("3", "Low Fat", "🥑"), - StackCardChip("4", "Balanced Marcos", "⚖️") - ) - ), - StackCard( - id = "2", - title = "two", - subTitle = "This is the dummy sub-title, and this is the first text", - color = Color(0xFFFBCB7F), - chips = listOf( - StackCardChip("1", "High Protein", "🍗"), - StackCardChip("2", "Low Carb", "🥒"), - StackCardChip("3", "Low Fat", "🥑"), - StackCardChip("4", "Balanced Marcos", "⚖️"), - StackCardChip("5", "High Protein", "🍗"), - StackCardChip("6", "Low Carb", "🥒"), - StackCardChip("7", "Low Fat", "🥑"), - StackCardChip("8", "Balanced Marcos", "⚖️") - ) - ), - StackCard( - id = "3", - title = "three", - subTitle = "This is the dummy sub-title, and this is the first text hughwrhugw oighwioghiowhgo woihgiowhgiow oigwhioghwiog owirhgiorwhgiowrh woighiowrhgiowrg oighrwioghiorwhgiohrwg", - color = Color(0xFFFF3D6E), - chips = listOf( - StackCardChip("1", "High Protein", "🍗"), - StackCardChip("2", "Low Carb", "🥒"), - StackCardChip("3", "Low Fat", "🥑"), - StackCardChip("4", "Balanced Marcos", "⚖️") - ) - ) - ) - - MaterialTheme { - Column( - modifier = Modifier.Companion.fillMaxSize(), - verticalArrangement = Arrangement.Center - ) { - Box( - modifier = Modifier.Companion - .background(Color(0xFFF5F5F0)) - .padding(16.dp) - ) { - StackCards(cards = sample, modifier = Modifier.Companion) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackedCardsComponent.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackedCardsComponent.kt new file mode 100644 index 0000000..2d142df --- /dev/null +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackedCardsComponent.kt @@ -0,0 +1,347 @@ +package lc.fungee.Ingredicheck.onboarding.ui.components +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times +import kotlinx.coroutines.launch +import lc.fungee.Ingredicheck.onboarding.data.OnboardingChipData +import lc.fungee.Ingredicheck.onboarding.ui.AvoidOptionChip +import lc.fungee.Ingredicheck.onboarding.ui.SimpleFlowRow +import lc.fungee.Ingredicheck.ui.theme.Greyscale140 +import lc.fungee.Ingredicheck.ui.theme.Greyscale150 +import lc.fungee.Ingredicheck.ui.theme.Manrope +import lc.fungee.Ingredicheck.ui.theme.Nunito +import kotlin.math.roundToInt +import androidx.core.graphics.toColorInt + +/** + * A composable that displays a stack of cards with swipe-to-rotate functionality. + * The second card (from top) is rotated by 6 degrees to show the stack effect. + * When a card is swiped left, it moves to the bottom and the next card becomes the top. + */ +@Composable +fun StackedCardsComponent( + modifier: Modifier = Modifier, + cardContent: @Composable (index: Int, isTop: Boolean) -> Unit +) { + // Create 6 cards with initial indices + val initialCards = remember { + mutableStateListOf(0, 1, 2, 3, 4, 5) + } + val cards = remember { initialCards } + + // Track drag offset for the top card + val dragOffsetX = remember { Animatable(0f) } + val dragOffsetY = remember { Animatable(0f) } + val isDragging = remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + val density = LocalDensity.current + val swipeThreshold = with(density) { 200.dp.toPx() } // Fallback absolute threshold + + // Track card width so we can compute 20% drag distance. + var cardWidth by remember { mutableStateOf(0f) } + val thirtyPercentThreshold = cardWidth * 0.2f // 20% of card width + + // Reset drag offset immediately when cards are reordered + // Use the top card index as a key to detect when it changes + val topCardIndex = remember(cards.size) { cards.lastOrNull() ?: -1 } + + LaunchedEffect(topCardIndex) { + // When top card changes, reset drag offset immediately + dragOffsetX.snapTo(0f) + dragOffsetY.snapTo(0f) + } + + with(density) { 20.dp.toPx() } + + Box( + modifier = modifier + .fillMaxWidth() + .height(260.dp) + .padding(horizontal = 20.dp) + ) { + // Render cards from bottom to top (so top card is rendered last and appears on top) + cards.forEachIndexed { stackIndex, cardIndex -> + val isTopCard = stackIndex == cards.lastIndex + val isSecondCard = stackIndex == cards.lastIndex - 1 + + // Calculate z-index offset (cards behind are slightly offset) + val zIndexOffset = (cards.lastIndex - stackIndex) * 2.dp + + // Rotation: + // - Top card should be 0° + // - Second card (just behind top) should rest at 6° + // and animate smoothly back to 0° when it becomes the top card. + val targetRotation = when { + isTopCard -> 0f + isSecondCard -> 6f + else -> 0f + } + val rotation by animateFloatAsState( + targetValue = targetRotation, + animationSpec = tween( + durationMillis = 1800, // extra slow, very smooth glide 6° -> 0° + easing = FastOutSlowInEasing + ), + label = "stackCardRotation" + ) + + // Alpha: only show top 2 cards (top card and second card), hide the rest + val alpha = if (stackIndex >= cards.lastIndex - 1) 1f else 0f + + // Scale: only top 2 cards are visible, so no scaling needed + val scale = 1f + + Box( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + .offset(x = zIndexOffset, y = zIndexOffset) + .alpha(alpha) + .rotate(rotation) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + // Back card visual treatment: subtle blur layer like iOS + .let { base -> + if (isSecondCard && !isTopCard) { + base.blur( + radius = 4.dp, + edgeTreatment = BlurredEdgeTreatment.Unbounded + ) + } else { + base + } + } + .then( + if (isTopCard) { + Modifier + .onGloballyPositioned { coordinates -> + cardWidth = coordinates.size.width.toFloat() + } + .offset { + IntOffset( + dragOffsetX.value.roundToInt(), + dragOffsetY.value.roundToInt() + ) + } + .pointerInput(cardIndex) { + detectDragGestures( + onDragStart = { isDragging.value = true }, + onDragEnd = { + isDragging.value = false + val currentOffsetX = dragOffsetX.value + // Use 30% of card width when known, otherwise fall back to a + // fixed px threshold; support BOTH directions (left & right). + val baseThreshold = + if (cardWidth > 0f) thirtyPercentThreshold + else swipeThreshold + + // If swiped beyond threshold to LEFT or RIGHT, complete swipe + // with a slower, smooth animation so the user can see the card + // travelling off-screen. + if (currentOffsetX <= -baseThreshold || + currentOffsetX >= baseThreshold + ) { + coroutineScope.launch { + val width = + if (cardWidth > 0f) cardWidth else swipeThreshold + val targetX = + if (currentOffsetX >= 0f) width * 1.4f + else -width * 1.4f + + // Animate card fully out of the screen + dragOffsetX.animateTo( + targetX, + animationSpec = tween( + durationMillis = 420, + easing = FastOutSlowInEasing + ) + ) + + // Reorder: move top card to bottom + val topCard = cards.removeAt(cards.lastIndex) + cards.add(0, topCard) + + // Reset offsets for the new top card + dragOffsetX.snapTo(0f) + dragOffsetY.snapTo(0f) + } + } else { + // Snap back if not swiped far enough + coroutineScope.launch { + dragOffsetX.animateTo( + 0f, + animationSpec = tween( + durationMillis = 280, + easing = FastOutSlowInEasing + ) + ) + dragOffsetY.animateTo( + 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + } + } + }, + onDrag = { change, dragAmount -> + change.consume() + coroutineScope.launch { + // Free horizontal dragging (primarily left); no clamping, + // so the card can visually reach / go past the screen edge. + val newOffsetX = dragOffsetX.value + dragAmount.x + dragOffsetX.snapTo(newOffsetX) + // Slight vertical offset for natural feel + dragOffsetY.snapTo(dragAmount.y * 0.3f) + } + } + ) + } + } else { + Modifier.Companion + } + ) + ) { + // Only the top card should reveal its full content (text, etc.). + // Cards behind can render a simplified/placeholder state if desired. + cardContent(cardIndex, isTopCard) + } + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5) +@Composable +private fun StackedCardsAvoidPreview() { + val cards = OnboardingChipData.avoidCards + val total = cards.size + + Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { + StackedCardsComponent( +// modifier = Modifier.padding(horizontal = 20.dp), + cardContent = { index, isTop -> + val card = cards[index % cards.size] + + val bgColor = try { + androidx.compose.ui.graphics.Color(card.colorHex.toColorInt()) + } catch (_: IllegalArgumentException) { + androidx.compose.ui.graphics.Color(0xFFFFF6B3) + } + + Box( + modifier = Modifier.Companion + .fillMaxWidth() + .height(260.dp) + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(24.dp), + spotColor = androidx.compose.ui.graphics.Color.Companion.Black.copy( + alpha = 0.15f, + ) + ) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(24.dp)) + .background(bgColor) + .padding(horizontal = 12.dp, vertical = 14.dp) + ) { + if (isTop) { + Column( + modifier = Modifier.Companion.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.Companion.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Companion.CenterVertically + ) { + Text( + text = card.title, + fontFamily = Nunito, + fontSize = 16.sp, + fontWeight = FontWeight.Companion.Bold, + color = Greyscale150 + ) + Text( + text = "${index + 1}/$total", + fontFamily = Manrope, + fontWeight = FontWeight.Companion.Normal, + fontSize = 14.sp, + color = Greyscale140 + ) + } + + Text( + text = card.description, + fontFamily = Manrope, + fontWeight = FontWeight.Companion.Normal, + fontSize = 12.sp, + color = Greyscale140 + ) + + Spacer(modifier = Modifier.Companion.height(6.dp)) + + SimpleFlowRow( + horizontalSpacing = 8.dp, + verticalSpacing = 8.dp + ) { + card.options.forEach { opt -> + AvoidOptionChip( + option = opt, + isSelected = false, + onClick = {} + ) + } + } + } + } + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/leaf_arrow_circlepath.xml b/app/src/main/res/drawable/leaf_arrow_circlepath.xml new file mode 100644 index 0000000..7988263 --- /dev/null +++ b/app/src/main/res/drawable/leaf_arrow_circlepath.xml @@ -0,0 +1,11 @@ + + + From 5eaedd1d267efe8a6d931d004685f205f92e8f88 Mon Sep 17 00:00:00 2001 From: Gaurav-eightinity01 Date: Thu, 19 Feb 2026 16:20:41 +0530 Subject: [PATCH 05/18] Add StackedCardsComponent and update onboarding flow Introduced `StackedCardsComponent`, a swipeable card stack UI with smooth rotation and drag animations. This component is integrated into the onboarding flow for the "Avoid" step, allowing users to navigate through categorized dietary restrictions (Oils & Fats, Animal-Based, etc.) via card swipes. Key changes: - Created `StackedCardsComponent` with support for horizontal dragging, exit animations, and "stack" visual effects. - Added `AvoidOptionChip` and `SimpleFlowRow` to support the new card-based selection UI. - Expanded `OnboardingChipData` to include `avoidCards` definitions and additional chip categories for ethical and taste preferences. - Updated `AllergyScreens` to utilize the new stacked cards UI for the specific "Avoid" question step. - Added a new vector resource `leaf_arrow_circlepath.xml` used as a background element in the cards. - Removed the legacy `StackCards.kt` implementation. --- .../onboarding/data/OnboardingData.kt | 108 +++- .../onboarding/ui/AllergyScreens.kt | 505 ++++++++++++++++-- .../onboarding/ui/OnboardingHost.kt | 54 +- .../onboarding/ui/StackedCardsComponent.kt | 38 +- .../ui/components/CapsuleStepperRow.kt | 22 +- .../ui/components/StackedCardsComponent.kt | 34 +- .../res/drawable/leaf_arrow_circlepath.xml | 14 +- 7 files changed, 654 insertions(+), 121 deletions(-) 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 508dae6..af9bfed 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 @@ -47,7 +47,7 @@ object OnboardingChipData { * Returns the (question, subtitle) pair for the given fine‑tune step. */ fun questionForStep(step: Int): Pair { - return when (step.coerceIn(0, 5)) { + 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 @@ -61,6 +61,12 @@ object OnboardingChipData { "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." } @@ -122,6 +128,24 @@ object OnboardingChipData { ChipDefinition("none_lifestage", "None of these apply", "✅ ") ) + // 4 – Region / cultural practices (subRegions as chips for capsule display) + 4 -> regions.flatMap { it.subRegions } + + // 5 – Avoid (stacked card options as chips for capsule display) + 5 -> avoidCards.flatMap { it.options }.map { o -> + ChipDefinition(o.id, o.label, o.iconPrefix) + } + + // 6 – LifeStyle (stacked card options for capsule display) + 6 -> lifestyleCards.flatMap { it.options }.map { o -> + ChipDefinition(o.id, o.label, o.iconPrefix) + } + + // 7 – Nutrition (stacked card options for capsule display) + 7 -> nutritionCards.flatMap { it.options }.map { o -> + ChipDefinition(o.id, o.label, o.iconPrefix) + } + // 8 – Ethical preferences 8 -> listOf( ChipDefinition("ethical_animal_welfare", "Animal welfare focused", "🐄 "), @@ -220,6 +244,88 @@ object OnboardingChipData { ) ) + /** 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. diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt index 9c973c9..1e0d996 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.core.FastOutSlowInEasing @@ -26,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -72,6 +74,8 @@ import lc.fungee.Ingredicheck.onboarding.model.OnboardingViewModel import lc.fungee.Ingredicheck.onboarding.ui.components.StackedCardsComponent import lc.fungee.Ingredicheck.ui.components.buttons.primaryButtonEffect import lc.fungee.Ingredicheck.ui.components.buttons.primaryChipEffect +import lc.fungee.Ingredicheck.ui.components.buttons.PrimaryButton +import lc.fungee.Ingredicheck.ui.components.buttons.SecondaryButton import lc.fungee.Ingredicheck.ui.theme.Greyscale10 import lc.fungee.Ingredicheck.ui.theme.Greyscale40 import lc.fungee.Ingredicheck.ui.theme.Greyscale70 @@ -91,6 +95,8 @@ internal fun AddAllergiesSheet( onMemberSelected: (String) -> Unit, onToggleAllergy: (String) -> Unit, onNext: () -> Unit, + onSkipPreferences: () -> Unit = {}, + showFineTuneDecision: Boolean = false, questionStepIndex: Int = 0 ) { val everyoneId = "ALL" @@ -106,8 +112,66 @@ internal fun AddAllergiesSheet( } } + // Special fine‑tune decision screen between Life Style and Nutrition. + if (showFineTuneDecision) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Want to fine‑tune your experience?", + fontFamily = Manrope, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = Greyscale150, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "Add extra preferences to tailor your experience.\nJump in or skip!", + fontFamily = Manrope, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + color = Greyscale120, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // "All Set!" – secondary (outlined) button + SecondaryButton( + title = "All Set!", + modifier = Modifier.weight(1f), + onClick = { onSkipPreferences() }, + takeFullWidth = true + ) + + // "Add Preferences" – primary (filled) button + PrimaryButton( + title = "Add Preferences", + modifier = Modifier.weight(1f), + onClick = { onNext() }, + takeFullWidth = true + ) + } + } + return + } + AnimatedContent( - targetState = questionStepIndex.coerceIn(0, 5), + targetState = questionStepIndex.coerceIn(0, 9), label = "allergyQuestion", transitionSpec = { fadeIn(animationSpec = tween(durationMillis = 250)) togetherWith @@ -348,7 +412,7 @@ internal fun AddAllergiesSheet( modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 20.dp, bottom = 16.dp) - .size(56.dp) + .size(50.dp) .primaryButtonEffect( isDisabled = false, shape = RoundedCornerShape(percent = 50), @@ -370,8 +434,7 @@ internal fun AddAllergiesSheet( } } } else if (questionStepIndex == 5) { - // Avoid step: show stacked cards instead of chips, and - // do NOT render the forward arrow button here. + // Avoid step: show stacked cards with forward arrow button always visible val avoidCards = OnboardingChipData.avoidCards val totalCards = avoidCards.size @@ -382,8 +445,9 @@ internal fun AddAllergiesSheet( ) { StackedCardsComponent( modifier = Modifier, - cardContent = { index, isTop -> - val card = avoidCards[index % avoidCards.size] + cardCount = avoidCards.size, + cardContent = { index, isTop, positionInStack -> + val card = avoidCards[index] val bgColor = try { Color(android.graphics.Color.parseColor(card.colorHex)) @@ -391,6 +455,16 @@ internal fun AddAllergiesSheet( Color(0xFFFFF6B3) } + // Smooth fade-in of card content when this card becomes top (0 -> 1 opacity) + val contentAlpha by animateFloatAsState( + targetValue = if (isTop) 1f else 0f, + animationSpec = tween( + durationMillis = 750, + easing = FastOutSlowInEasing + ), + label = "cardContentAlpha" + ) + Box( modifier = Modifier .fillMaxWidth() @@ -406,7 +480,9 @@ internal fun AddAllergiesSheet( ) { if (isTop) { Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .alpha(contentAlpha), verticalArrangement = Arrangement.spacedBy(4.dp) ) { Row( @@ -422,7 +498,7 @@ internal fun AddAllergiesSheet( color = Greyscale150 ) Text( - text = "${index + 1}/$totalCards", + text = "${positionInStack}/$totalCards", fontFamily = Manrope, fontWeight = FontWeight.Normal, fontSize = 14.sp, @@ -456,71 +532,388 @@ internal fun AddAllergiesSheet( } } - Image( - painter = painterResource(id = R.drawable.leaf_arrow_circlepath), - contentDescription = null, - modifier = Modifier - .align(Alignment.BottomEnd) - .size(100.dp), - contentScale = ContentScale.Fit - ) + if (isTop) { + // Adjust leaf icon position (positive X = right, positive Y = down) + val leafIconOffsetX = 5.dp + val leafIconOffsetY = 34.dp + Image( + painter = painterResource(id = R.drawable.leaf_arrow_circlepath), + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = leafIconOffsetX, y = leafIconOffsetY) + .height(100.dp) + .alpha(contentAlpha), + contentScale = ContentScale.Fit + ) + } } } ) + + // Fixed-position forward arrow button (always visible) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 16.dp) + .size(56.dp) + .primaryButtonEffect( + isDisabled = false, + shape = RoundedCornerShape(percent = 50), + disabledBackgroundColor = Greyscale40 + ) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onNext() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ArrowForward, + contentDescription = null, + tint = Greyscale10, + modifier = Modifier.size(24.dp) + ) + } } - } else { - val allergies = remember(questionStepIndex) { - OnboardingChipData.chipsForStep(questionStepIndex) - } + } else if (questionStepIndex == 6) { + // LifeStyle step: same stacked cards as Avoid, 3 cards (Plant & Balance, Quality & Source, Sustainable Living) + val lifestyleCards = OnboardingChipData.lifestyleCards + val totalCards = lifestyleCards.size Box( modifier = Modifier .fillMaxWidth() - .animateContentSize( - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow + .height(300.dp) + ) { + StackedCardsComponent( + modifier = Modifier, + cardCount = lifestyleCards.size, + cardContent = { index, isTop, positionInStack -> + val card = lifestyleCards[index] + + val bgColor = try { + Color(android.graphics.Color.parseColor(card.colorHex)) + } catch (_: IllegalArgumentException) { + Color(0xFFFFF6B3) + } + + val contentAlpha by animateFloatAsState( + targetValue = if (isTop) 1f else 0f, + animationSpec = tween( + durationMillis = 750, + easing = FastOutSlowInEasing + ), + label = "cardContentAlpha" ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(270.dp) + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(24.dp), + spotColor = Color.Black.copy(alpha = 0.15f) + ) + .clip(RoundedCornerShape(24.dp)) + .background(bgColor) + .padding(horizontal = 12.dp, vertical = 16.dp) + ) { + if (isTop) { + Column( + modifier = Modifier + .fillMaxSize() + .alpha(contentAlpha), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = card.title, + fontFamily = Nunito, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Greyscale150 + ) + Text( + text = "${positionInStack}/$totalCards", + fontFamily = Manrope, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + color = Greyscale140 + ) + } + + Text( + text = card.description, + fontFamily = Manrope, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + color = Greyscale140 + ) + + Spacer(modifier = Modifier.height(6.dp)) + + SimpleFlowRow( + horizontalSpacing = 8.dp, + verticalSpacing = 8.dp + ) { + card.options.forEach { opt -> + val isSelected = selectedAllergies.contains(opt.id) + AvoidOptionChip( + option = opt, + isSelected = isSelected, + onClick = { onToggleAllergy(opt.id) } + ) + } + } + } + } + + if (isTop) { + val leafIconOffsetX = 5.dp + val leafIconOffsetY = 34.dp + Image( + painter = painterResource(id = R.drawable.leaf_arrow_circlepath), + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = leafIconOffsetX, y = leafIconOffsetY) + .height(100.dp) + .alpha(contentAlpha), + contentScale = ContentScale.Fit + ) + } + } + } + ) + + // Fixed-position forward arrow button (always visible) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 16.dp) + .size(56.dp) + .primaryButtonEffect( + isDisabled = false, + shape = RoundedCornerShape(percent = 50), + disabledBackgroundColor = Greyscale40 + ) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onNext() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ArrowForward, + contentDescription = null, + tint = Greyscale10, + modifier = Modifier.size(24.dp) ) - .clip(RoundedCornerShape(22.dp)) - .background(Color.White) - .padding(horizontal = 20.dp) + } + } + } else if (questionStepIndex == 7) { + // Nutrition step: 3 cards (Macronutrient Goals, Sugar & Fiber, Diet Frameworks & Patterns) + val nutritionCards = OnboardingChipData.nutritionCards + val totalCards = nutritionCards.size + + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) ) { - Column { - FlowRowWithRightAlignedButton( - modifier = Modifier.fillMaxWidth(), - horizontalSpacing = 8.dp, - verticalSpacing = 8.dp - ) { - allergies.forEach { def -> - val isSelected = selectedAllergies.contains(def.id) - AllergyChip( - label = def.iconPrefix + def.label, - selected = isSelected, - onClick = { onToggleAllergy(def.id) } - ) + StackedCardsComponent( + modifier = Modifier, + cardCount = nutritionCards.size, + cardContent = { index, isTop, positionInStack -> + val card = nutritionCards[index] + + val bgColor = try { + Color(android.graphics.Color.parseColor(card.colorHex)) + } catch (_: IllegalArgumentException) { + Color(0xFFFFF6B3) } + + val contentAlpha by animateFloatAsState( + targetValue = if (isTop) 1f else 0f, + animationSpec = tween( + durationMillis = 750, + easing = FastOutSlowInEasing + ), + label = "cardContentAlpha" + ) + Box( modifier = Modifier - .size(56.dp) - .primaryButtonEffect( - isDisabled = false, - shape = RoundedCornerShape(percent = 50), - disabledBackgroundColor = Greyscale40 + .fillMaxWidth() + .height(270.dp) + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(24.dp), + spotColor = Color.Black.copy(alpha = 0.15f) ) - .clip(CircleShape) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { onNext() }, - contentAlignment = Alignment.Center + .clip(RoundedCornerShape(24.dp)) + .background(bgColor) + .padding(horizontal = 12.dp, vertical = 16.dp) ) { - Icon( - imageVector = Icons.Filled.ArrowForward, - contentDescription = null, - tint = Greyscale10, - modifier = Modifier.size(24.dp) + if (isTop) { + Column( + modifier = Modifier + .fillMaxSize() + .alpha(contentAlpha), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = card.title, + fontFamily = Nunito, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Greyscale150 + ) + Text( + text = "${positionInStack}/$totalCards", + fontFamily = Manrope, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + color = Greyscale140 + ) + } + + Text( + text = card.description, + fontFamily = Manrope, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + color = Greyscale140 + ) + + Spacer(modifier = Modifier.height(6.dp)) + + SimpleFlowRow( + horizontalSpacing = 8.dp, + verticalSpacing = 8.dp + ) { + card.options.forEach { opt -> + val isSelected = selectedAllergies.contains(opt.id) + AvoidOptionChip( + option = opt, + isSelected = isSelected, + onClick = { onToggleAllergy(opt.id) } + ) + } + } + } + } + + if (isTop) { + val leafIconOffsetX = 5.dp + val leafIconOffsetY = 34.dp + Image( + painter = painterResource(id = R.drawable.leaf_arrow_circlepath), + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = leafIconOffsetX, y = leafIconOffsetY) + .height(100.dp) + .alpha(contentAlpha), + contentScale = ContentScale.Fit + ) + } + } + } + ) + + // Fixed-position forward arrow button (always visible) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 16.dp) + .size(56.dp) + .primaryButtonEffect( + isDisabled = false, + shape = RoundedCornerShape(percent = 50), + disabledBackgroundColor = Greyscale40 + ) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onNext() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ArrowForward, + contentDescription = null, + tint = Greyscale10, + modifier = Modifier.size(24.dp) + ) + } + } + } else { + val allergies = remember(questionStepIndex) { + OnboardingChipData.chipsForStep(questionStepIndex) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow + ) + ) + .clip(RoundedCornerShape(22.dp)) + .background(Color.White) + .padding(horizontal = 20.dp) + ) { + Column { + FlowRowWithRightAlignedButton( + modifier = Modifier.fillMaxWidth(), + horizontalSpacing = 8.dp, + verticalSpacing = 8.dp + ) { + allergies.forEach { def -> + val isSelected = selectedAllergies.contains(def.id) + AllergyChip( + label = def.iconPrefix + def.label, + selected = isSelected, + onClick = { onToggleAllergy(def.id) } + ) + } + Box( + modifier = Modifier + .size(56.dp) + .primaryButtonEffect( + isDisabled = false, + shape = RoundedCornerShape(percent = 50), + disabledBackgroundColor = Greyscale40 ) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onNext() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ArrowForward, + contentDescription = null, + tint = Greyscale10, + modifier = Modifier.size(24.dp) + ) } } } diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt index 2edfd7f..5af8610 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt @@ -887,7 +887,10 @@ fun OnboardingHost( CapsuleStep("taste", "Taste", R.drawable.iconoir_chocolate) ) } + var allergyStepIndex by remember { mutableStateOf(0) } + // When true, show the fine‑tune decision screen between Life Style and Nutrition + var showFineTuneDecision by remember { mutableStateOf(false) } if (step == OnboardingStep.GET_STARTED) { GetStatedScreen( @@ -1023,6 +1026,9 @@ fun OnboardingHost( Spacer(modifier = Modifier.height(40.dp)) // Animate progress based on current allergyStepIndex. + // NOTE: The fine‑tune decision screen between Life Style and Nutrition + // does NOT advance allergyStepIndex, so progress will not increase + // while that screen is shown. val rawProgress = if (allergySteps.size <= 1) 1f else allergyStepIndex.toFloat() / (allergySteps.size - 1).coerceAtLeast(1) @@ -1042,8 +1048,12 @@ fun OnboardingHost( steps = allergySteps, activeIndex = allergyStepIndex, onStepClick = { clickedIndex -> - allergyStepIndex = - clickedIndex.coerceIn(0, allergySteps.lastIndex) + // Only allow jumping to steps the user has already visited. + val clamped = clickedIndex.coerceIn(0, allergySteps.lastIndex) + if (clamped <= allergyStepIndex) { + allergyStepIndex = clamped + showFineTuneDecision = false + } } ) @@ -1051,13 +1061,14 @@ fun OnboardingHost( Spacer(modifier = Modifier.height(10.dp)) // Show a scrollable list of CapsuleSkeletonBox cards. - // Steps 0-3 correspond to: Allergies, Intolerances, Health Conditions, Life Stage. + // Steps 0–9: Allergies, Intolerances, Health, Life Stage, Region, Avoid, LifeStyle, Nutrition, Ethical, Taste. val hasAnySelections = selectedAllergies.isNotEmpty() + val maxStepIndex = allergySteps.lastIndex // Decide which step indices should render cards. val cardSteps: List = if (hasAnySelections) { - // Only steps that have at least one selected chip. - (0..3).filter { stepIndex -> + // Only steps that have at least one selected chip (including Region, Avoid, LifeStyle, Nutrition). + (0..maxStepIndex).filter { stepIndex -> val stepChipIds = OnboardingChipData .chipsForStep(stepIndex) .map { it.id } @@ -1065,8 +1076,8 @@ fun OnboardingHost( selectedAllergies.any { it in stepChipIds } } } else { - // No selections yet – show all four as empty placeholders. - (0..3).toList() + // No selections yet – show all steps as empty placeholders. + (0..maxStepIndex).toList() } val cardsListState = rememberLazyListState() @@ -1208,9 +1219,10 @@ fun OnboardingHost( "selectedAllergiesByMember.keys=${selectedAllergiesByMember.keys}" ) - // Key on revision (bumped every tap) so the sheet always recomposes when - // chips are toggled. Use activeMemberSelections which is explicitly updated. - key(activeMemberKey, allergySelectionRevision, activeMemberSelections.sorted().joinToString(",")) { + // Key only by member so switching member resets the sheet; do NOT key by + // selections so that chip toggles do not recreate the sheet (preserves + // stacked card order when user selects chips on 2nd/3rd card). + key(activeMemberKey) { AddAllergiesSheet( members = vm.familyOverviewMembers.toList(), selectedMemberId = selectedAllergyMemberIdState.value, @@ -1280,13 +1292,27 @@ fun OnboardingHost( ) }, onNext = { - // Advance to next fine‑tune step visually; once at the end, exit onboarding. - if (allergyStepIndex < allergySteps.lastIndex) { - allergyStepIndex++ + // Between Life Style (index 6) and Nutrition (index 7), + // show a dedicated fine‑tune decision screen that does + // NOT advance progress until the user confirms. + if (allergyStepIndex == 6 && !showFineTuneDecision) { + showFineTuneDecision = true } else { - onExitOnboarding() + showFineTuneDecision = false + if (allergyStepIndex < allergySteps.lastIndex) { + allergyStepIndex++ + } else { + onExitOnboarding() + } } }, + onSkipPreferences = { + // User tapped "All Set!" on the fine‑tune decision screen: + // close the decision and exit the onboarding fine‑tune flow. + showFineTuneDecision = false + onExitOnboarding() + }, + showFineTuneDecision = showFineTuneDecision, questionStepIndex = allergyStepIndex ) } diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/StackedCardsComponent.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/StackedCardsComponent.kt index c3e78f8..a2ca605 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/StackedCardsComponent.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/StackedCardsComponent.kt @@ -134,7 +134,7 @@ fun StackedCardsComponent( val rotation by animateFloatAsState( targetValue = targetRotation, animationSpec = tween( - durationMillis = 1800, // extra slow, very smooth glide 6° -> 0° + durationMillis = 2600, // very smooth, slow glide 6° -> 0° when card becomes top easing = FastOutSlowInEasing ), label = "stackCardRotation" @@ -175,10 +175,8 @@ fun StackedCardsComponent( cardWidth = coordinates.size.width.toFloat() } .offset { - IntOffset( - dragOffsetX.value.roundToInt(), - dragOffsetY.value.roundToInt() - ) + // Horizontal only: no vertical movement + IntOffset(dragOffsetX.value.roundToInt(), 0) } .pointerInput(cardIndex) { detectDragGestures( @@ -244,12 +242,9 @@ fun StackedCardsComponent( onDrag = { change, dragAmount -> change.consume() coroutineScope.launch { - // Free horizontal dragging (primarily left); no clamping, - // so the card can visually reach / go past the screen edge. + // Horizontal only: card stays stable vertically val newOffsetX = dragOffsetX.value + dragAmount.x dragOffsetX.snapTo(newOffsetX) - // Slight vertical offset for natural feel - dragOffsetY.snapTo(dragAmount.y * 0.3f) } } ) @@ -288,7 +283,7 @@ private fun StackedCardsAvoidPreview() { Box( modifier = Modifier .fillMaxWidth() - .height(260.dp) + .height(280.dp) .shadow( elevation = 8.dp, shape = RoundedCornerShape(24.dp), @@ -349,15 +344,20 @@ private fun StackedCardsAvoidPreview() { } } - Image( - painter = painterResource(id = R.drawable.leaf_arrow_circlepath), - contentDescription = null, - modifier = Modifier - .align(Alignment.BottomEnd) - .height(56.dp) - .alpha(0.22f), - contentScale = ContentScale.Fit - ) + if (isTop) { + // Adjust leaf icon position (positive X = right, positive Y = down) + val leafIconOffsetX = 5.dp + val leafIconOffsetY = 34.dp + Image( + painter = painterResource(id = R.drawable.leaf_arrow_circlepath), + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = leafIconOffsetX, y = leafIconOffsetY) + .height(100.dp), + contentScale = ContentScale.Fit + ) + } } } ) diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt index 6a84492..cc1cc3b 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/CapsuleStepperRow.kt @@ -63,7 +63,8 @@ fun CapsuleStepperRow( lineHeight: Dp = 15.dp, horizontalPadding: Dp = 16.dp, itemSpacing: Dp = 10.dp, - animationDurationMs: Int = 280 + animationDurationMs: Int = 280, + progressExcludedIndex: Int? = null // Step index that should not count toward progress fill ) { if (steps.isEmpty()) return @@ -73,6 +74,13 @@ fun CapsuleStepperRow( if (clampedActive > maxReachedIndex) { maxReachedIndex = clampedActive } + + // Adjust maxReachedIndex for progress calculation: exclude the excluded step + val effectiveMaxReachedIndex = if (progressExcludedIndex != null && maxReachedIndex > progressExcludedIndex) { + maxReachedIndex - 1 + } else { + maxReachedIndex + } // Layout model (fixed widths so we can compute the progress fill length deterministically) val collapsedHeight = 44.dp @@ -172,7 +180,15 @@ fun CapsuleStepperRow( ) { steps.forEachIndexed { index, step -> val isActive = index == clampedActive - val isVisited = index <= maxReachedIndex + // For progress fill, exclude the excluded step from "visited" calculation + val effectiveIndex = if (progressExcludedIndex != null && index > progressExcludedIndex) { + index - 1 + } else { + index + } + val isVisited = effectiveIndex <= effectiveMaxReachedIndex + // Only allow clicking on visited steps (users must progress sequentially via forward arrow) + val isClickable = isVisited && onStepClick != null val bg = if (isVisited) activeColor else inactiveColor val iconTint = if (isVisited) Color.Companion.White else Color(0xFFC4E092) @@ -201,7 +217,7 @@ fun CapsuleStepperRow( } ) .clickable( - enabled = onStepClick != null, + enabled = isClickable, interactionSource = remember { MutableInteractionSource() }, indication = null ) { onStepClick?.invoke(index) }, diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackedCardsComponent.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackedCardsComponent.kt index 2d142df..fa3950d 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackedCardsComponent.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/components/StackedCardsComponent.kt @@ -64,13 +64,12 @@ import androidx.core.graphics.toColorInt @Composable fun StackedCardsComponent( modifier: Modifier = Modifier, - cardContent: @Composable (index: Int, isTop: Boolean) -> Unit + cardCount: Int = 6, + cardContent: @Composable (index: Int, isTop: Boolean, positionInStack: Int) -> Unit ) { - // Create 6 cards with initial indices - val initialCards = remember { - mutableStateListOf(0, 1, 2, 3, 4, 5) + val cards = remember(cardCount) { + mutableStateListOf().apply { addAll(0 until cardCount) } } - val cards = remember { initialCards } // Track drag offset for the top card val dragOffsetX = remember { Animatable(0f) } @@ -164,10 +163,8 @@ fun StackedCardsComponent( cardWidth = coordinates.size.width.toFloat() } .offset { - IntOffset( - dragOffsetX.value.roundToInt(), - dragOffsetY.value.roundToInt() - ) + // Horizontal only: no vertical movement + IntOffset(dragOffsetX.value.roundToInt(), 0) } .pointerInput(cardIndex) { detectDragGestures( @@ -234,12 +231,9 @@ fun StackedCardsComponent( onDrag = { change, dragAmount -> change.consume() coroutineScope.launch { - // Free horizontal dragging (primarily left); no clamping, - // so the card can visually reach / go past the screen edge. + // Horizontal only: card stays fixed vertically val newOffsetX = dragOffsetX.value + dragAmount.x dragOffsetX.snapTo(newOffsetX) - // Slight vertical offset for natural feel - dragOffsetY.snapTo(dragAmount.y * 0.3f) } } ) @@ -249,9 +243,9 @@ fun StackedCardsComponent( } ) ) { - // Only the top card should reveal its full content (text, etc.). - // Cards behind can render a simplified/placeholder state if desired. - cardContent(cardIndex, isTopCard) + // positionInStack: 1 = top card, 2 = second, etc. (so "1/N" is always the visible top card) + val positionInStack = cards.lastIndex - stackIndex + 1 + cardContent(cardIndex, isTopCard, positionInStack) } } } @@ -265,9 +259,9 @@ private fun StackedCardsAvoidPreview() { Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { StackedCardsComponent( -// modifier = Modifier.padding(horizontal = 20.dp), - cardContent = { index, isTop -> - val card = cards[index % cards.size] + cardCount = cards.size, + cardContent = { index, isTop, positionInStack -> + val card = cards[index] val bgColor = try { androidx.compose.ui.graphics.Color(card.colorHex.toColorInt()) @@ -308,7 +302,7 @@ private fun StackedCardsAvoidPreview() { color = Greyscale150 ) Text( - text = "${index + 1}/$total", + text = "${positionInStack}/$total", fontFamily = Manrope, fontWeight = FontWeight.Companion.Normal, fontSize = 14.sp, diff --git a/app/src/main/res/drawable/leaf_arrow_circlepath.xml b/app/src/main/res/drawable/leaf_arrow_circlepath.xml index 7988263..5d752cf 100644 --- a/app/src/main/res/drawable/leaf_arrow_circlepath.xml +++ b/app/src/main/res/drawable/leaf_arrow_circlepath.xml @@ -1,11 +1,9 @@ + android:width="86dp" + android:height="94dp" + android:viewportWidth="86" + android:viewportHeight="94"> + android:pathData="M43.328,93.38C66.638,93.033 85.725,73.41 85.376,50.059C85.167,35.998 77.865,23.467 66.992,15.801C65.132,14.405 62.835,14.901 61.815,16.632C60.794,18.404 61.368,20.364 63.061,21.637C72.071,27.864 78.043,38.282 78.263,50.165C78.599,69.92 62.933,85.973 43.222,86.267C23.469,86.561 7.456,70.977 7.162,51.226C6.909,34.278 18.333,20.002 34.099,16.167L34.189,22.193C34.233,25.164 36.295,25.93 38.529,24.262L51.781,14.689C53.645,13.364 53.658,11.355 51.711,10.002L38.223,0.827C35.895,-0.812 33.858,0.013 33.903,3.027L33.991,8.926C14.461,13.027 -0.3,30.744 0.007,51.332C0.356,74.683 19.977,93.729 43.328,93.38ZM20.869,35.661C20.689,37.63 20.552,39.559 20.58,41.441C20.793,55.71 30.414,66.618 42.634,66.436C47.99,66.356 52.936,63.979 55.056,60.139C57.018,62.621 58.025,65.619 59.643,70.283C59.817,70.7 60.237,70.862 60.613,70.645L62.066,69.913C63.147,69.436 63.515,68.844 63.498,67.714C63.451,64.575 59.405,59.989 57.328,58.137C48.629,50.273 35.475,55.115 32.078,46.375C31.993,46.21 31.949,46.127 31.947,46.002C31.942,45.666 32.231,45.41 32.483,45.406C32.776,45.402 32.987,45.567 33.201,45.772C39.871,52.286 48.95,46.543 57.395,54.286C57.989,54.822 58.864,54.557 58.896,53.846C58.927,53.174 58.999,52.337 58.985,51.458C58.842,41.874 52.002,37.875 42.251,38.021C39.239,38.066 36.147,38.487 33.218,38.53C29.117,38.591 25.422,37.895 22.871,35.17C22.191,34.468 20.938,34.654 20.869,35.661Z" + android:fillColor="#ffffff"/> From 22c459b7783dd2c3f4c58c4d9e9060177502f861 Mon Sep 17 00:00:00 2001 From: Gaurav-eightinity01 Date: Thu, 19 Feb 2026 18:36:38 +0530 Subject: [PATCH 06/18] Refactor onboarding navigation button and UI layout - Consolidate the forward arrow button into a single global component positioned at the bottom-right of the onboarding sheet, replacing multiple redundant instances across different steps. - Update the forward arrow icon to use a new custom vector drawable (`forward_arow_line_1`) with refined styling. - Replace `FlowRowWithRightAlignedButton` with a simpler `SimpleFlowRow` for allergy chip layouts. - Enhance the `AllergyRegionCard` by wrapping sub-region chips in an `AnimatedVisibility` container for smoother expansion transitions. - Adjust header styling in `AllergyRegionCard` to only apply the primary selection effect when a sub-region is actually selected, improving visual clarity. --- .../onboarding/ui/AllergyScreens.kt | 300 +++++------------- .../main/res/drawable/forward_arow_line_1.xml | 20 ++ 2 files changed, 99 insertions(+), 221 deletions(-) create mode 100644 app/src/main/res/drawable/forward_arow_line_1.xml diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt index 1e0d996..71c18a2 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt @@ -1,6 +1,7 @@ package lc.fungee.Ingredicheck.onboarding.ui import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -35,7 +36,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Icon @@ -408,30 +408,6 @@ internal fun AddAllergiesSheet( } // Fixed-position primary arrow button (does NOT scroll) - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 20.dp, bottom = 16.dp) - .size(50.dp) - .primaryButtonEffect( - isDisabled = false, - shape = RoundedCornerShape(percent = 50), - disabledBackgroundColor = Greyscale40 - ) - .clip(CircleShape) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { onNext() }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.ArrowForward, - contentDescription = null, - tint = Greyscale10, - modifier = Modifier.size(24.dp) - ) - } } } else if (questionStepIndex == 5) { // Avoid step: show stacked cards with forward arrow button always visible @@ -551,31 +527,6 @@ internal fun AddAllergiesSheet( } ) - // Fixed-position forward arrow button (always visible) - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 20.dp, bottom = 16.dp) - .size(56.dp) - .primaryButtonEffect( - isDisabled = false, - shape = RoundedCornerShape(percent = 50), - disabledBackgroundColor = Greyscale40 - ) - .clip(CircleShape) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { onNext() }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.ArrowForward, - contentDescription = null, - tint = Greyscale10, - modifier = Modifier.size(24.dp) - ) - } } } else if (questionStepIndex == 6) { // LifeStyle step: same stacked cards as Avoid, 3 cards (Plant & Balance, Quality & Source, Sustainable Living) @@ -693,31 +644,6 @@ internal fun AddAllergiesSheet( } ) - // Fixed-position forward arrow button (always visible) - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 20.dp, bottom = 16.dp) - .size(56.dp) - .primaryButtonEffect( - isDisabled = false, - shape = RoundedCornerShape(percent = 50), - disabledBackgroundColor = Greyscale40 - ) - .clip(CircleShape) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { onNext() }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.ArrowForward, - contentDescription = null, - tint = Greyscale10, - modifier = Modifier.size(24.dp) - ) - } } } else if (questionStepIndex == 7) { // Nutrition step: 3 cards (Macronutrient Goals, Sugar & Fiber, Diet Frameworks & Patterns) @@ -835,92 +761,80 @@ internal fun AddAllergiesSheet( } ) - // Fixed-position forward arrow button (always visible) - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 20.dp, bottom = 16.dp) - .size(56.dp) - .primaryButtonEffect( - isDisabled = false, - shape = RoundedCornerShape(percent = 50), - disabledBackgroundColor = Greyscale40 + } + } else { + val allergies = remember(questionStepIndex) { + OnboardingChipData.chipsForStep(questionStepIndex) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow ) - .clip(CircleShape) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { onNext() }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.ArrowForward, - contentDescription = null, - tint = Greyscale10, - modifier = Modifier.size(24.dp) ) + .clip(RoundedCornerShape(22.dp)) + .background(Color.White) + .padding(horizontal = 20.dp) + ) { + Column { + SimpleFlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalSpacing = 8.dp, + verticalSpacing = 8.dp + ) { + allergies.forEach { def -> + val isSelected = selectedAllergies.contains(def.id) + AllergyChip( + label = def.iconPrefix + def.label, + selected = isSelected, + onClick = { onToggleAllergy(def.id) } + ) + } + } } } - } else { - val allergies = remember(questionStepIndex) { - OnboardingChipData.chipsForStep(questionStepIndex) } + // Single global forward arrow button at the bottom-right of the sheet Box( modifier = Modifier .fillMaxWidth() - .animateContentSize( - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow + .padding(vertical = 10.dp , horizontal = 20.dp) + + , + contentAlignment = Alignment.BottomEnd + ) { + Box( + modifier = Modifier + .size(56.dp) + .primaryButtonEffect( + isDisabled = false, + shape = RoundedCornerShape(percent = 50), + disabledBackgroundColor = Greyscale40 ) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onNext() }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.forward_arow_line_1), + contentDescription = null, + tint = Color.Unspecified , + modifier = Modifier.size(32.dp) ) - .clip(RoundedCornerShape(22.dp)) - .background(Color.White) - .padding(horizontal = 20.dp) - ) { - Column { - FlowRowWithRightAlignedButton( - modifier = Modifier.fillMaxWidth(), - horizontalSpacing = 8.dp, - verticalSpacing = 8.dp - ) { - allergies.forEach { def -> - val isSelected = selectedAllergies.contains(def.id) - AllergyChip( - label = def.iconPrefix + def.label, - selected = isSelected, - onClick = { onToggleAllergy(def.id) } - ) - } - Box( - modifier = Modifier - .size(56.dp) - .primaryButtonEffect( - isDisabled = false, - shape = RoundedCornerShape(percent = 50), - disabledBackgroundColor = Greyscale40 - ) - .clip(CircleShape) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { onNext() }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.ArrowForward, - contentDescription = null, - tint = Greyscale10, - modifier = Modifier.size(24.dp) - ) - } - } - } } } } + + @Composable private fun AllergyChip( label: String, @@ -1064,7 +978,8 @@ private fun RegionSectionRow( ) { val shape = RoundedCornerShape(999.dp) val headerModifier = - if (isSectionSelected || isExpanded) { + if (isSectionSelected) { + // Region header uses primary effect only when any sub‑region is selected. Modifier.primaryChipEffect(shape) } else { Modifier @@ -1091,31 +1006,33 @@ private fun RegionSectionRow( fontFamily = Manrope, fontWeight = FontWeight.Medium, fontSize = 14.sp, - color = if (isSectionSelected || isExpanded) Greyscale10 else Greyscale150, + color = if (isSectionSelected) Greyscale10 else Greyscale150, modifier = Modifier.weight(1f, fill = false) ) Icon( imageVector = if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, contentDescription = null, - tint = if (isSectionSelected || isExpanded) Greyscale10 else Greyscale120, + tint = if (isSectionSelected) Greyscale10 else Greyscale120, modifier = Modifier.size(18.dp) ) } } - if (isExpanded) { - Spacer(modifier = Modifier.height(8.dp)) - SimpleFlowRow( - horizontalSpacing = 8.dp, - verticalSpacing = 8.dp - ) { - region.subRegions.forEach { def -> - val isSelected = selectedAllergies.contains(def.id) - AllergyChip( - label = def.iconPrefix + def.label, - selected = isSelected, - onClick = { onToggleAllergy(def.id) } - ) + AnimatedVisibility(visible = isExpanded) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + SimpleFlowRow( + horizontalSpacing = 8.dp, + verticalSpacing = 8.dp + ) { + region.subRegions.forEach { def -> + val isSelected = selectedAllergies.contains(def.id) + AllergyChip( + label = def.iconPrefix + def.label, + selected = isSelected, + onClick = { onToggleAllergy(def.id) } + ) + } } } } @@ -1168,62 +1085,3 @@ fun SimpleFlowRow( } } } - -@Composable -private fun FlowRowWithRightAlignedButton( - modifier: Modifier = Modifier, - horizontalSpacing: Dp, - verticalSpacing: Dp, - content: @Composable () -> Unit -) { - Layout(content = content, modifier = modifier) { measurables, constraints -> - val spacingX = horizontalSpacing.roundToPx() - val spacingY = verticalSpacing.roundToPx() - if (measurables.isEmpty()) { - return@Layout layout(0, 0) {} - } - val chips = measurables.dropLast(1) - val button = measurables.last() - val chipPlaceables = chips.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) } - val buttonPlaceable = button.measure(constraints.copy(minWidth = 0, minHeight = 0)) - val maxWidth = constraints.maxWidth - var x = 0 - var y = 0 - var rowHeight = 0 - val chipPositions = ArrayList(chipPlaceables.size) - chipPlaceables.forEach { p -> - if (x > 0 && x + p.width > maxWidth) { - x = 0 - y += rowHeight + spacingY - rowHeight = 0 - } - chipPositions.add(intArrayOf(x, y)) - x += p.width + spacingX - rowHeight = maxOf(rowHeight, p.height) - } - val lastRowStartY = if (chipPositions.isNotEmpty()) chipPositions.last()[1] else 0 - val lastRowRightmostX = if (chipPositions.isNotEmpty()) { - chipPlaceables.mapIndexedNotNull { index, chip -> - if (chipPositions[index][1] == lastRowStartY) chipPositions[index][0] + chip.width else null - }.maxOrNull() ?: 0 - } else 0 - val buttonFitsOnLastRow = lastRowRightmostX + spacingX + buttonPlaceable.width <= maxWidth - val buttonX = maxWidth - buttonPlaceable.width - val buttonY: Int - if (buttonFitsOnLastRow && chipPlaceables.isNotEmpty()) { - buttonY = lastRowStartY - rowHeight = maxOf(rowHeight, buttonPlaceable.height) - } else { - buttonY = y + rowHeight + spacingY - rowHeight = buttonPlaceable.height - } - val totalHeight = (buttonY + rowHeight).coerceIn(constraints.minHeight, constraints.maxHeight) - layout(width = maxWidth, height = totalHeight) { - chipPlaceables.forEachIndexed { i, p -> - val pos = chipPositions[i] - p.placeRelative(pos[0], pos[1]) - } - buttonPlaceable.placeRelative(buttonX, buttonY) - } - } -} diff --git a/app/src/main/res/drawable/forward_arow_line_1.xml b/app/src/main/res/drawable/forward_arow_line_1.xml new file mode 100644 index 0000000..8f4fc69 --- /dev/null +++ b/app/src/main/res/drawable/forward_arow_line_1.xml @@ -0,0 +1,20 @@ + + + + From 07c79d8410a942607ed48546c1c4b76edd428be4 Mon Sep 17 00:00:00 2001 From: Gaurav-eightinity01 Date: Thu, 19 Feb 2026 18:47:26 +0530 Subject: [PATCH 07/18] Add warning message for "other" selections in onboarding - Add `emoji_warning` vector drawable asset. - Update `OnboardingHost` to detect if any selected chip ID contains the term "other". - Display a warning icon and a "Something else too, don't worry we'll ask later!" message when an "other" option is selected during onboarding. --- .../onboarding/ui/OnboardingHost.kt | 27 +++++++++++++++++++ app/src/main/res/drawable/emoji_warning.xml | 12 +++++++++ 2 files changed, 39 insertions(+) create mode 100644 app/src/main/res/drawable/emoji_warning.xml diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt index 5af8610..3d6b8a9 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt @@ -107,7 +107,9 @@ import lc.fungee.Ingredicheck.onboarding.model.OnboardingViewModelFactory import lc.fungee.Ingredicheck.onboarding.ui.components.AnimatedProgressLine import lc.fungee.Ingredicheck.onboarding.ui.components.CapsuleStep import lc.fungee.Ingredicheck.onboarding.ui.components.CapsuleStepperRow +import lc.fungee.Ingredicheck.ui.theme.Greyscale100 import lc.fungee.Ingredicheck.ui.theme.Greyscale110 +import lc.fungee.Ingredicheck.ui.theme.Greyscale120 import lc.fungee.Ingredicheck.ui.theme.Greyscale150 import lc.fungee.Ingredicheck.ui.theme.Greyscale60 import lc.fungee.Ingredicheck.ui.theme.Manrope @@ -371,6 +373,10 @@ private fun CapsuleSkeletonBox( val resolvedChips = remember(selectedChipIds) { selectedChipIds.mapNotNull { id -> OnboardingChipData.chipForId(id) } } + val hasOtherSelection = remember(selectedChipIds) { + // Any chip id containing "other" (e.g. "other", "other_sens", "region_*_other", etc.) + selectedChipIds.any { it.contains("other", ignoreCase = true) } + } BoxWithConstraints( modifier = modifier @@ -422,6 +428,27 @@ private fun CapsuleSkeletonBox( ) } } + if (hasOtherSelection) { + Spacer(modifier = Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + painter = painterResource(R.drawable.emoji_warning), + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color.Unspecified + ) + Text( + text = "Something else too, don't worry we'll ask later!", + fontFamily = Manrope, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + color = Greyscale100 + ) + } + } } } else { val maxWidth = maxWidth diff --git a/app/src/main/res/drawable/emoji_warning.xml b/app/src/main/res/drawable/emoji_warning.xml new file mode 100644 index 0000000..e0b1287 --- /dev/null +++ b/app/src/main/res/drawable/emoji_warning.xml @@ -0,0 +1,12 @@ + + + + From 74d9547279616fd21f4e54bc8b6dd3195913c3e2 Mon Sep 17 00:00:00 2001 From: Gaurav-eightinity01 Date: Fri, 20 Feb 2026 12:40:25 +0530 Subject: [PATCH 08/18] Onboarding & allergy UI: Everyone default, avatar styling, step rename, fine-tune buttons - OnboardingHost: default selected member to Everyone (ALL) on first launch - AllergyScreens: member avatar uses memoji background color (backgroundColorId/colorHex); fixed 52dp container to prevent layout shift; scaling inner circle with avatar; no-memoji placeholder uses member color + initial letter - OnboardingStep: ADD_FAMILY_FALLING_CAPSULES renamed to FALLING_CAPSULES (both flows) - AuthViewModel: update reference to FALLING_CAPSULES - Fine-tune decision: All Set! text #75990E, both buttons 16sp Nunito SemiBold Co-authored-by: Cursor --- .../fungee/Ingredicheck/auth/AuthViewModel.kt | 2 +- .../onboarding/model/OnboardingStep.kt | 2 +- .../onboarding/ui/AllergyScreens.kt | 139 +++++++++++++----- .../onboarding/ui/OnboardingHost.kt | 11 +- 4 files changed, 109 insertions(+), 45 deletions(-) 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 791fb99..65d4a12 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/auth/AuthViewModel.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/auth/AuthViewModel.kt @@ -431,7 +431,7 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) { OnboardingStep.ADD_FAMILY_AVATAR_GENERATING, OnboardingStep.ADD_FAMILY_ALL_SET_OR_MORE, OnboardingStep.ADD_FAMILY_EDIT_MEMBER, - OnboardingStep.ADD_FAMILY_FALLING_CAPSULES, + OnboardingStep.FALLING_CAPSULES, OnboardingStep.ADD_FAMILY_ALLERGIES -> "pre_onboarding" } diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/model/OnboardingStep.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/model/OnboardingStep.kt index a89d8e9..5a85816 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/model/OnboardingStep.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/model/OnboardingStep.kt @@ -13,6 +13,6 @@ enum class OnboardingStep { ADD_FAMILY_AVATAR_GENERATING, ADD_FAMILY_ALL_SET_OR_MORE, ADD_FAMILY_EDIT_MEMBER, - ADD_FAMILY_FALLING_CAPSULES, + FALLING_CAPSULES, ADD_FAMILY_ALLERGIES } diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt index 71c18a2..df5b3fb 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt @@ -83,10 +83,36 @@ import lc.fungee.Ingredicheck.ui.theme.Greyscale100 import lc.fungee.Ingredicheck.ui.theme.Greyscale120 import lc.fungee.Ingredicheck.ui.theme.Greyscale140 import lc.fungee.Ingredicheck.ui.theme.Greyscale150 +import lc.fungee.Ingredicheck.ui.theme.Greyscale30 import lc.fungee.Ingredicheck.ui.theme.Manrope import lc.fungee.Ingredicheck.ui.theme.Nunito +import lc.fungee.Ingredicheck.ui.theme.NunitoSemiBold import lc.fungee.Ingredicheck.ui.theme.Primary700 +private fun avatarBackgroundColorForId(colorId: String): Color { + return when (colorId) { + "color_pastel_blue" -> Color(0xFFA5D8FF) + "color_warm_pink" -> Color(0xFFFFB3C1) + "color_soft_green" -> Color(0xFFB9FBC0) + "color_lavender" -> Color(0xFFE3B8FF) + "color_orange" -> Color(0xFFFFB74D) + "color_yellow" -> Color(0xFFFFE082) + "color_transparent" -> Color.Transparent + else -> Color.White + } +} + +/** Resolves member avatar background: memoji color if set, else random pastel (colorHex) from member creation. */ +private fun memberAvatarBackgroundColor(backgroundColorId: String, colorHex: String): Color { + if (backgroundColorId.isNotBlank()) return avatarBackgroundColorForId(backgroundColorId) + if (colorHex.isNotBlank()) { + return kotlin.runCatching { + Color(android.graphics.Color.parseColor(colorHex)) + }.getOrElse { Color.White } + } + return Color.White +} + @Composable internal fun AddAllergiesSheet( members: List, @@ -117,13 +143,14 @@ internal fun AddAllergiesSheet( Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 24.dp), + .padding(horizontal = 20.dp) + .padding(bottom = 6.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = "Want to fine‑tune your experience?", - fontFamily = Manrope, + fontFamily = Nunito, fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Greyscale150, @@ -150,20 +177,23 @@ internal fun AddAllergiesSheet( .padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - // "All Set!" – secondary (outlined) button + // "All Set!" – secondary (outlined) button; 16sp Nunito SemiBold, text #75990E SecondaryButton( title = "All Set!", modifier = Modifier.weight(1f), onClick = { onSkipPreferences() }, - takeFullWidth = true + takeFullWidth = true, + textColor = Color(0xFF75990E), + textStyle = NunitoSemiBold.copy(fontSize = 16.sp) ) - // "Add Preferences" – primary (filled) button + // "Add Preferences" – primary (filled) button; 16sp Nunito SemiBold PrimaryButton( title = "Add Preferences", modifier = Modifier.weight(1f), onClick = { onNext() }, - takeFullWidth = true + takeFullWidth = true, + textStyle = NunitoSemiBold.copy(fontSize = 16.sp) ) } } @@ -231,14 +261,18 @@ internal fun AddAllergiesSheet( ) { Box( modifier = Modifier - .size(48.dp) + .size(52.dp) .clip(CircleShape) + .background( + if (isSelected) Primary700.copy(alpha = 0.2f) + else Color.White, + shape = CircleShape + ) .border( width = borderWidth, color = if (isSelected) Primary700 else Color.Unspecified, shape = CircleShape - ) - .background(Color.White), + ), contentAlignment = Alignment.Center ) { Image( @@ -279,11 +313,14 @@ internal fun AddAllergiesSheet( indication = null ) { onMemberSelected(m.id) } ) { + val memberBgColor = remember(m.backgroundColorId, m.colorHex) { + memberAvatarBackgroundColor(m.backgroundColorId, m.colorHex) + } Box( modifier = Modifier - .size(48.dp) + .size(52.dp) .clip(CircleShape) - .background(Color.White) + .background(Color.White, shape = CircleShape) .border( width = borderWidth, color = if (isSelected) Primary700 else Color.Unspecified, @@ -291,27 +328,39 @@ internal fun AddAllergiesSheet( ), contentAlignment = Alignment.Center ) { - when { - m.generatedAvatarUrl.trim().isNotBlank() -> { - AsyncImage( - model = m.generatedAvatarUrl.trim(), - contentDescription = null, - modifier = Modifier.size(avatarSize).clip(CircleShape), - contentScale = ContentScale.Crop - ) - } - avatarRes != null -> { - Image( - painter = painterResource(id = avatarRes), - contentDescription = null, - modifier = Modifier.size(avatarSize).clip(CircleShape), - contentScale = ContentScale.Crop - ) - } - else -> { - Box( - modifier = Modifier.size(36.dp).clip(CircleShape).background(Greyscale40) - ) + Box( + modifier = Modifier + .size(avatarSize) + .clip(CircleShape) + .background(memberBgColor, shape = CircleShape), + contentAlignment = Alignment.Center + ) { + when { + m.generatedAvatarUrl.trim().isNotBlank() -> { + AsyncImage( + model = m.generatedAvatarUrl.trim(), + contentDescription = null, + modifier = Modifier.size(avatarSize).clip(CircleShape), + contentScale = ContentScale.Crop + ) + } + avatarRes != null -> { + Image( + painter = painterResource(id = avatarRes), + contentDescription = null, + modifier = Modifier.size(avatarSize).clip(CircleShape), + contentScale = ContentScale.Crop + ) + } + else -> { + Text( + text = m.name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?", + fontFamily = Manrope, + fontWeight = FontWeight.SemiBold, + fontSize = if (isSelected) 16.sp else 14.sp, + color = Greyscale120 + ) + } } } } @@ -417,7 +466,8 @@ internal fun AddAllergiesSheet( Box( modifier = Modifier .fillMaxWidth() - .height(300.dp) + .height(280.dp) + ) { StackedCardsComponent( modifier = Modifier, @@ -453,6 +503,7 @@ internal fun AddAllergiesSheet( .clip(RoundedCornerShape(24.dp)) .background(bgColor) .padding(horizontal = 12.dp, vertical = 16.dp) + ) { if (isTop) { Column( @@ -536,7 +587,7 @@ internal fun AddAllergiesSheet( Box( modifier = Modifier .fillMaxWidth() - .height(300.dp) + .height(280.dp) ) { StackedCardsComponent( modifier = Modifier, @@ -653,7 +704,7 @@ internal fun AddAllergiesSheet( Box( modifier = Modifier .fillMaxWidth() - .height(300.dp) + .height(280.dp) ) { StackedCardsComponent( modifier = Modifier, @@ -803,7 +854,8 @@ internal fun AddAllergiesSheet( Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 10.dp , horizontal = 20.dp) + .padding( horizontal = 20.dp) + .padding(top = 4.dp ,end =10.dp) , contentAlignment = Alignment.BottomEnd @@ -995,7 +1047,8 @@ private fun RegionSectionRow( interactionSource = remember { MutableInteractionSource() }, indication = null ) { onToggleExpanded() } - .padding(horizontal = 16.dp, vertical = 10.dp) + .padding(start = 16.dp , end = 6.dp) + .padding( vertical = 8.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -1009,13 +1062,23 @@ private fun RegionSectionRow( color = if (isSectionSelected) Greyscale10 else Greyscale150, modifier = Modifier.weight(1f, fill = false) ) + Box( + modifier = Modifier + .size(24.dp) // circle size + .background( + color = Greyscale30 , + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { Icon( imageVector = if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, contentDescription = null, - tint = if (isSectionSelected) Greyscale10 else Greyscale120, + tint = Greyscale100, modifier = Modifier.size(18.dp) ) } + } } AnimatedVisibility(visible = isExpanded) { diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt index 3d6b8a9..8cf47d8 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt @@ -880,15 +880,16 @@ fun OnboardingHost( val isAddMoreMemberNoBack = step == OnboardingStep.ADD_FAMILY_NAME && vm.familyOverviewMembers.size == 1 val isAllSetOrMoreScreen = step == OnboardingStep.ADD_FAMILY_ALL_SET_OR_MORE - val isFallingCapsulesScreen = step == OnboardingStep.ADD_FAMILY_FALLING_CAPSULES + val isFallingCapsulesScreen = step == OnboardingStep.FALLING_CAPSULES BackHandler(enabled = true) { if (!isAddMoreMemberNoBack && !isAllSetOrMoreScreen && !isFallingCapsulesScreen) { handleBack() } } + // On first launch show "Everyone" as selected (ALL); user can switch to a member later. val selectedAllergyMemberIdState = remember(vm.familyOverviewMembers.size) { - mutableStateOf(vm.familyOverviewMembers.firstOrNull()?.id.orEmpty()) + mutableStateOf("ALL") } val selectedAllergies = remember { mutableStateListOf() } // memberKey ("ALL" or member.id) -> set of chipIds selected for that member @@ -938,7 +939,7 @@ fun OnboardingHost( OnboardingStep.ADD_FAMILY_AVATAR_GENERATING, OnboardingStep.ADD_FAMILY_ALL_SET_OR_MORE, OnboardingStep.ADD_FAMILY_EDIT_MEMBER -> 4 - OnboardingStep.ADD_FAMILY_FALLING_CAPSULES -> 5 + OnboardingStep.FALLING_CAPSULES -> 5 OnboardingStep.ADD_FAMILY_ALLERGIES -> 6 else -> 0 } @@ -1212,7 +1213,7 @@ fun OnboardingHost( ) { s -> Column { when (s) { - OnboardingStep.ADD_FAMILY_FALLING_CAPSULES -> { + OnboardingStep.FALLING_CAPSULES -> { AddFamilyLetsGoSheet( onLetsGo = { vm.navigateTo(OnboardingStep.ADD_FAMILY_ALLERGIES) @@ -1595,7 +1596,7 @@ fun OnboardingHost( OnboardingStep.ADD_FAMILY_ALL_SET_OR_MORE -> { AddFamilyAllSetOrMoreSheet( - onAllSet = { vm.navigateTo(OnboardingStep.ADD_FAMILY_FALLING_CAPSULES) }, + onAllSet = { vm.navigateTo(OnboardingStep.FALLING_CAPSULES) }, onAddMore = { vm.navigateTo(OnboardingStep.ADD_FAMILY_NAME) } ) } From c1045ded702a2de345618bdebe1521cb711bea24 Mon Sep 17 00:00:00 2001 From: Gaurav-eightinity01 Date: Fri, 20 Feb 2026 13:23:19 +0530 Subject: [PATCH 09/18] Reusable onboarding utils + Dietary Preference backend sync (iOS-aligned) Reusable: - OnboardingAvatarUtils: shared avatarBackgroundColorForId + memberAvatarBackgroundColor - OnboardingData: EVERYONE_MEMBER_ID constant, labelForChipId for preference text - OnboardingAnimations: AvatarSelectionTween for avatar selection animation - AllergyScreens, OnboardingHost, OnboardingStepScreens use shared color/constant/anim Dietary Preference: - dietary/: DTOs, DietaryPreferenceRepository (GET/POST/PUT/DELETE preferencelists/default) - AuthViewModel.syncDietaryPreferencesFromOnboarding(preferenceText) with logs - OnboardingHost: buildDietaryPreferenceText(), sync on All Set! and on last-step complete - DIETARY_PREFERENCE.md explains flow and logs Co-authored-by: Cursor --- .../fungee/Ingredicheck/auth/AuthViewModel.kt | 47 +++++ .../dietary/DIETARY_PREFERENCE.md | 31 +++ .../dietary/DietaryPreferenceDto.kt | 15 ++ .../dietary/DietaryPreferenceRepository.kt | 189 ++++++++++++++++++ .../onboarding/data/OnboardingAvatarUtils.kt | 34 ++++ .../onboarding/data/OnboardingData.kt | 7 + .../onboarding/ui/AllergyScreens.kt | 42 +--- .../onboarding/ui/OnboardingAnimations.kt | 17 ++ .../onboarding/ui/OnboardingHost.kt | 49 +++-- .../onboarding/ui/OnboardingStepScreens.kt | 45 +---- 10 files changed, 376 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/lc/fungee/Ingredicheck/dietary/DIETARY_PREFERENCE.md create mode 100644 app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceDto.kt create mode 100644 app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceRepository.kt create mode 100644 app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/OnboardingAvatarUtils.kt create mode 100644 app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingAnimations.kt 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 65d4a12..dcba791 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/auth/AuthViewModel.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/auth/AuthViewModel.kt @@ -17,6 +17,7 @@ import lc.fungee.Ingredicheck.onboarding.model.OnboardingStep import lc.fungee.Ingredicheck.memoji.MemojiRepository import lc.fungee.Ingredicheck.memoji.MemojiRequestMapper import lc.fungee.Ingredicheck.family.FamilyMemberDto +import lc.fungee.Ingredicheck.dietary.DietaryPreferenceRepository enum class AuthProvider { Google, @@ -43,6 +44,7 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) { private val repository = AuthRepository(app.applicationContext) private val memojiRepository = MemojiRepository() private val familyRepository = FamilyRepository() + private val dietaryPreferenceRepository = DietaryPreferenceRepository() private val _state = MutableStateFlow(AuthState.Idle) val state: StateFlow = _state @@ -62,6 +64,51 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) { Log.d("AuthDebug", message) } + /** + * Sync onboarding fine-tune selections to the backend as one dietary preference (same as iOS). + * Called when user taps "All Set!" or completes the last preference step. + * Logs success/failure for debugging. + */ + fun syncDietaryPreferencesFromOnboarding(preferenceText: String) { + if (preferenceText.isBlank()) { + Log.d("AuthDebug", "DietaryPreference: skip sync (empty text)") + return + } + viewModelScope.launch { + val accessToken = repository.accessTokenOrNull() + if (accessToken.isNullOrBlank()) { + Log.w("AuthDebug", "DietaryPreference: skip sync (no access token)") + return@launch + } + pushDebug("DietaryPreference: syncing length=${preferenceText.length}") + val clientActivityId = java.util.UUID.randomUUID().toString() + val result = dietaryPreferenceRepository.addOrEditDietaryPreference( + accessToken = accessToken, + clientActivityId = clientActivityId, + preferenceText = preferenceText, + id = null + ) + result.fold( + onSuccess = { validationResult -> + 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}") + } + is lc.fungee.Ingredicheck.dietary.PreferenceValidationResult.Failure -> { + pushDebug("DietaryPreference: sync failure ${validationResult.explanation}") + Log.w("AuthDebug", "DietaryPreference: sync failure ${validationResult.explanation}") + } + } + }, + onFailure = { e -> + pushDebug("DietaryPreference: sync error ${e.message}") + Log.e("AuthDebug", "DietaryPreference: sync error", e) + } + ) + } + } + 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 new file mode 100644 index 0000000..f1665e1 --- /dev/null +++ b/app/src/main/java/lc/fungee/Ingredicheck/dietary/DIETARY_PREFERENCE.md @@ -0,0 +1,31 @@ +# Dietary Preference (Android) – What it is and how it’s implemented + +## What it is + +**Dietary preference** is the user’s list of food-related preferences (allergies, intolerances, lifestyle, etc.) stored on the backend. It is used for “add family” and “just me” in the same way: one list per signed-in user. + +## How it’s implemented + +1. **Backend API** (aligned with iOS) + - `GET /preferencelists/default` → list of saved preferences + - `POST /preferencelists/default` (form: `clientActivityId`, `preference`) → add one + - `PUT /preferencelists/default/{id}` (form: `clientActivityId`, `preference`) → edit one + - `DELETE /preferencelists/default/{id}` (form: `clientActivityId`) → delete one + +2. **When we sync** + - When the user finishes the fine-tune flow: + - Taps **“All Set!”** on the decision screen, or + - Completes the **last step** (e.g. Taste) and taps next. + - We build one string from all selected chips (e.g. `"Peanuts, Tree nuts, Lactose"`) and send it as a single new preference via POST. + +3. **Where it runs** + - **OnboardingHost**: Builds the preference text from `selectedAllergiesByMember` and calls `authViewModel.syncDietaryPreferencesFromOnboarding(preferenceText)` in both exit paths. + - **AuthViewModel**: Gets the access token, calls `DietaryPreferenceRepository.addOrEditDietaryPreference(..., preferenceText, id = null)`. + - **DietaryPreferenceRepository**: Sends the request to the backend and logs success/failure. + +4. **Logs** + - `OnboardingAllergies`: `[DietaryPreference] onSkipPreferences: syncing textLength=...` / `onNext complete: syncing textLength=...` + - `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. diff --git a/app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceDto.kt b/app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceDto.kt new file mode 100644 index 0000000..f594c44 --- /dev/null +++ b/app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceDto.kt @@ -0,0 +1,15 @@ +package lc.fungee.Ingredicheck.dietary + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One dietary preference item from the backend (same as iOS DTO.DietaryPreference). + * Used for GET list and for add/edit success response. + */ +@Serializable +data class DietaryPreferenceDto( + val text: String, + @SerialName("annotatedText") val annotatedText: String = text, + val id: Int +) diff --git a/app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceRepository.kt b/app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceRepository.kt new file mode 100644 index 0000000..8ea5355 --- /dev/null +++ b/app/src/main/java/lc/fungee/Ingredicheck/dietary/DietaryPreferenceRepository.kt @@ -0,0 +1,189 @@ +package lc.fungee.Ingredicheck.dietary + +import android.util.Log +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.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +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.client.request.forms.formData +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.http.isSuccess +import java.util.concurrent.TimeUnit +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import lc.fungee.Ingredicheck.AppConfig + +private const val TAG = "DietaryPreference" + +/** + * Dietary Preference = the user's food preferences (allergies, intolerances, etc.) stored on the backend. + * + * Same as iOS: one list per user (add-family and just-me both use the same API). + * - When the user finishes the fine-tune flow ("All Set!" or last step), we build one text from + * all selected chips (e.g. "Peanuts, Tree nuts, Lactose") and POST it as one new preference. + * - Backend: GET/POST preferencelists/default, PUT/DELETE preferencelists/default/{id}. + */ +class DietaryPreferenceRepository { + + 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" + ) + + /** GET list of dietary preferences. */ + suspend fun getDietaryPreferences(accessToken: String): Result> { + val url = baseUrl("preferencelists/default") + 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 (!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}") + }.onFailure { e -> + Log.e(TAG, "getDietaryPreferences: error", e) + } + } + + /** + * Add (id=null) or edit (id!=null) one dietary preference. + * Form body: clientActivityId, preference; same as iOS. + */ + suspend fun addOrEditDietaryPreference( + accessToken: String, + clientActivityId: String, + preferenceText: String, + id: Int? + ): Result { + 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") + return runCatching { + val formBody = MultiPartFormDataContent(formData { + append("clientActivityId", clientActivityId) + append("preference", preferenceText) + }) + val response = if (id != null) { + client.put(url) { + authHeaders(accessToken).forEach { (k, v) -> header(k, v) } + accept(ContentType.Application.Json) + setBody(formBody) + } + } else { + client.post(url) { + authHeaders(accessToken).forEach { (k, v) -> header(k, v) } + accept(ContentType.Application.Json) + setBody(formBody) + } + } + val res = response as HttpResponse + val body = res.bodyAsText() + 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}") + } + parseValidationResult(body) + }.onSuccess { result -> + when (result) { + is PreferenceValidationResult.Success -> + Log.d(TAG, "addOrEditDietaryPreference: success id=${result.preference.id}") + is PreferenceValidationResult.Failure -> + Log.w(TAG, "addOrEditDietaryPreference: failure explanation=${result.explanation}") + } + }.onFailure { e -> + Log.e(TAG, "addOrEditDietaryPreference: error", e) + } + } + + /** DELETE one dietary preference. */ + suspend fun deleteDietaryPreference( + accessToken: String, + clientActivityId: String, + id: Int + ): Result { + val url = baseUrl("preferencelists/default/$id") + Log.d(TAG, "deleteDietaryPreference: DELETE $url id=$id") + return runCatching { + val formBody = MultiPartFormDataContent(formData { + append("clientActivityId", clientActivityId) + }) + val response: HttpResponse = client.delete(url) { + authHeaders(accessToken).forEach { (k, v) -> header(k, v) } + setBody(formBody) + } + Log.d(TAG, "deleteDietaryPreference: status=${response.status.value}") + if (response.status.value != 204 && !response.status.isSuccess()) { + throw IllegalStateException("DELETE failed: ${response.status.value}") + } + Unit + }.onFailure { e -> + Log.e(TAG, "deleteDietaryPreference: error", e) + } + } + + private fun parseValidationResult(body: String): PreferenceValidationResult { + val obj = json.parseToJsonElement(body).jsonObject + val result = obj["result"]?.jsonPrimitive?.content + return when (result) { + "success" -> { + val text = obj["text"]?.jsonPrimitive?.content ?: "" + val annotatedText = obj["annotatedText"]?.jsonPrimitive?.content ?: text + val id = obj["id"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0 + PreferenceValidationResult.Success(DietaryPreferenceDto(text, annotatedText, id)) + } + "failure" -> { + val explanation = obj["explanation"]?.jsonPrimitive?.content ?: "Unknown error" + PreferenceValidationResult.Failure(explanation) + } + else -> PreferenceValidationResult.Failure("Unexpected result: $result") + } + } +} + +/** Result of add/edit: either success with the saved item or failure with message. */ +sealed class PreferenceValidationResult { + data class Success(val preference: DietaryPreferenceDto) : PreferenceValidationResult() + data class Failure(val explanation: String) : PreferenceValidationResult() +} diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/OnboardingAvatarUtils.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/OnboardingAvatarUtils.kt new file mode 100644 index 0000000..0e965f0 --- /dev/null +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/data/OnboardingAvatarUtils.kt @@ -0,0 +1,34 @@ +package lc.fungee.Ingredicheck.onboarding.data + +import androidx.compose.ui.graphics.Color + +/** + * Shared avatar/memoji background color resolution used by onboarding UI + * (AllergyScreens, OnboardingHost, OnboardingStepScreens) and memoji flows. + */ + +fun avatarBackgroundColorForId(colorId: String?): Color { + return when (colorId) { + "color_pastel_blue" -> Color(0xFFA5D8FF) + "color_warm_pink" -> Color(0xFFFFB3C1) + "color_soft_green" -> Color(0xFFB9FBC0) + "color_lavender" -> Color(0xFFE3B8FF) + "color_orange" -> Color(0xFFFFB74D) + "color_yellow" -> Color(0xFFFFE082) + "color_transparent" -> Color.Transparent + else -> Color.White + } +} + +/** + * Resolves member avatar background: memoji color if set, else random pastel (colorHex) from member creation. + */ +fun memberAvatarBackgroundColor(backgroundColorId: String, colorHex: String): Color { + if (backgroundColorId.isNotBlank()) return avatarBackgroundColorForId(backgroundColorId) + if (colorHex.isNotBlank()) { + return kotlin.runCatching { + Color(android.graphics.Color.parseColor(colorHex)) + }.getOrElse { Color.White } + } + return Color.White +} 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 af9bfed..36df614 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 @@ -2,6 +2,9 @@ package lc.fungee.Ingredicheck.onboarding.data import lc.fungee.Ingredicheck.R +/** Member id used for "Everyone" in add-family and just-me flows. */ +const val EVERYONE_MEMBER_ID = "ALL" + /** * 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 @@ -428,5 +431,9 @@ object OnboardingChipData { /** 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 } diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt index df5b3fb..bd5832e 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/AllergyScreens.kt @@ -9,9 +9,9 @@ import androidx.compose.animation.togetherWith import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.spring import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -67,9 +67,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import lc.fungee.Ingredicheck.R +import lc.fungee.Ingredicheck.onboarding.data.EVERYONE_MEMBER_ID import lc.fungee.Ingredicheck.onboarding.data.OnboardingChipData import lc.fungee.Ingredicheck.onboarding.data.RegionDefinition import lc.fungee.Ingredicheck.onboarding.data.AvoidOptionDefinition +import lc.fungee.Ingredicheck.onboarding.data.avatarBackgroundColorForId +import lc.fungee.Ingredicheck.onboarding.data.memberAvatarBackgroundColor import lc.fungee.Ingredicheck.onboarding.model.OnboardingViewModel import lc.fungee.Ingredicheck.onboarding.ui.components.StackedCardsComponent import lc.fungee.Ingredicheck.ui.components.buttons.primaryButtonEffect @@ -88,30 +91,7 @@ import lc.fungee.Ingredicheck.ui.theme.Manrope import lc.fungee.Ingredicheck.ui.theme.Nunito import lc.fungee.Ingredicheck.ui.theme.NunitoSemiBold import lc.fungee.Ingredicheck.ui.theme.Primary700 - -private fun avatarBackgroundColorForId(colorId: String): Color { - return when (colorId) { - "color_pastel_blue" -> Color(0xFFA5D8FF) - "color_warm_pink" -> Color(0xFFFFB3C1) - "color_soft_green" -> Color(0xFFB9FBC0) - "color_lavender" -> Color(0xFFE3B8FF) - "color_orange" -> Color(0xFFFFB74D) - "color_yellow" -> Color(0xFFFFE082) - "color_transparent" -> Color.Transparent - else -> Color.White - } -} - -/** Resolves member avatar background: memoji color if set, else random pastel (colorHex) from member creation. */ -private fun memberAvatarBackgroundColor(backgroundColorId: String, colorHex: String): Color { - if (backgroundColorId.isNotBlank()) return avatarBackgroundColorForId(backgroundColorId) - if (colorHex.isNotBlank()) { - return kotlin.runCatching { - Color(android.graphics.Color.parseColor(colorHex)) - }.getOrElse { Color.White } - } - return Color.White -} +import lc.fungee.Ingredicheck.onboarding.ui.OnboardingAnimations @Composable internal fun AddAllergiesSheet( @@ -125,7 +105,7 @@ internal fun AddAllergiesSheet( showFineTuneDecision: Boolean = false, questionStepIndex: Int = 0 ) { - val everyoneId = "ALL" + val everyoneId = EVERYONE_MEMBER_ID val fallbackMembers = remember(members) { if (members.isNotEmpty()) members else emptyList() } @@ -244,12 +224,12 @@ internal fun AddAllergiesSheet( val isSelected = resolvedSelectedId == everyoneId val avatarSize by animateDpAsState( targetValue = if (isSelected) 42.dp else 36.dp, - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + animationSpec = OnboardingAnimations.AvatarSelectionTween, label = "everyoneAvatarSize" ) val borderWidth by animateDpAsState( targetValue = if (isSelected) 2.dp else 1.dp, - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + animationSpec = OnboardingAnimations.AvatarSelectionTween, label = "everyoneBorderWidth" ) Column( @@ -298,12 +278,12 @@ internal fun AddAllergiesSheet( val avatarRes = OnboardingChipData.avatarResOrNull(m.avatarId) val avatarSize by animateDpAsState( targetValue = if (isSelected) 42.dp else 36.dp, - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + animationSpec = OnboardingAnimations.AvatarSelectionTween, label = "memberAvatarSize" ) val borderWidth by animateDpAsState( targetValue = if (isSelected) 2.dp else 1.dp, - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + animationSpec = OnboardingAnimations.AvatarSelectionTween, label = "memberBorderWidth" ) Column( @@ -961,7 +941,7 @@ fun AvoidOptionChip( fontFamily = Manrope, fontWeight = FontWeight.Medium, fontSize = 16.sp, - color = if (isSelected) Greyscale10 else Color(0xFF303030), + color = if (isSelected) Greyscale150 else Color(0xFF303030), maxLines = 1 ) } diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingAnimations.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingAnimations.kt new file mode 100644 index 0000000..27e2af2 --- /dev/null +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingAnimations.kt @@ -0,0 +1,17 @@ +package lc.fungee.Ingredicheck.onboarding.ui + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.tween +import androidx.compose.ui.unit.Dp + +/** + * Shared animation specs for onboarding UI (e.g. avatar selection size/border). + */ +object OnboardingAnimations { + /** Tween used for avatar size and border when selection changes (180ms, FastOutSlowInEasing). */ + val AvatarSelectionTween: TweenSpec = tween( + durationMillis = 180, + easing = FastOutSlowInEasing + ) +} diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt index 8cf47d8..d1b808e 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingHost.kt @@ -81,8 +81,6 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.Alignment import androidx.compose.ui.layout.Layout -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.draw.shadow import lc.fungee.Ingredicheck.auth.AppleLoginWebViewActivity import lc.fungee.Ingredicheck.ui.theme.Nunito import lc.fungee.Ingredicheck.ui.components.NonDraggableBottomSheet @@ -100,7 +98,9 @@ import lc.fungee.Ingredicheck.family.CreateFamilyRequest import lc.fungee.Ingredicheck.family.FamilyMemberDto import lc.fungee.Ingredicheck.memoji.GetStatedScreen import lc.fungee.Ingredicheck.onboarding.model.OnboardingPersistence +import lc.fungee.Ingredicheck.onboarding.data.EVERYONE_MEMBER_ID import lc.fungee.Ingredicheck.onboarding.data.OnboardingChipData +import lc.fungee.Ingredicheck.onboarding.data.avatarBackgroundColorForId import lc.fungee.Ingredicheck.onboarding.model.OnboardingStep import lc.fungee.Ingredicheck.onboarding.model.OnboardingViewModel import lc.fungee.Ingredicheck.onboarding.model.OnboardingViewModelFactory @@ -109,7 +109,6 @@ import lc.fungee.Ingredicheck.onboarding.ui.components.CapsuleStep import lc.fungee.Ingredicheck.onboarding.ui.components.CapsuleStepperRow import lc.fungee.Ingredicheck.ui.theme.Greyscale100 import lc.fungee.Ingredicheck.ui.theme.Greyscale110 -import lc.fungee.Ingredicheck.ui.theme.Greyscale120 import lc.fungee.Ingredicheck.ui.theme.Greyscale150 import lc.fungee.Ingredicheck.ui.theme.Greyscale60 import lc.fungee.Ingredicheck.ui.theme.Manrope @@ -171,24 +170,15 @@ private fun familyPlaceholderColor(seed: String): Color { return palette[idx] } -private fun avatarBackgroundColorForId(colorId: String): Color { - return when (colorId) { - "color_pastel_blue" -> Color(0xFFA5D8FF) - "color_warm_pink" -> Color(0xFFFFB3C1) - "color_soft_green" -> Color(0xFFB9FBC0) - "color_lavender" -> Color(0xFFE3B8FF) - "color_orange" -> Color(0xFFFFB74D) - "color_yellow" -> Color(0xFFFFE082) - "color_transparent" -> Color.Transparent - else -> Color.White - } -} - - - private val SelectedPillBackground = Secondary200 private val PillShape = RoundedCornerShape(30.dp) +/** Build a single preference string from onboarding chip selections for backend sync (same as iOS). */ +private fun buildDietaryPreferenceText(selectedAllergiesByMember: Map>): String { + val allChipIds = selectedAllergiesByMember.values.flatMap { it.toList() }.toSet() + return allChipIds.map { OnboardingChipData.labelForChipId(it) }.joinToString(", ") +} + @Composable private fun SelectedChipPill( emoji: String, @@ -303,7 +293,7 @@ private fun CapsuleChipMemberAvatars( ) { if (memberIds.isEmpty()) return - val everyoneId = "ALL" + val everyoneId = EVERYONE_MEMBER_ID val hasEveryone = memberIds.contains(everyoneId) val concreteMemberIds = memberIds.filter { it != everyoneId }.toSet() val concreteMembers = members.filter { concreteMemberIds.contains(it.id) } @@ -889,7 +879,7 @@ fun OnboardingHost( // On first launch show "Everyone" as selected (ALL); user can switch to a member later. val selectedAllergyMemberIdState = remember(vm.familyOverviewMembers.size) { - mutableStateOf("ALL") + mutableStateOf(EVERYONE_MEMBER_ID) } val selectedAllergies = remember { mutableStateListOf() } // memberKey ("ALL" or member.id) -> set of chipIds selected for that member @@ -1112,7 +1102,7 @@ fun OnboardingHost( // Small avatar(s) to show who this capsule applies to (Everyone or a member) val activeMemberId = selectedAllergyMemberIdState.value - val everyoneIdCaps = "ALL" + val everyoneIdCaps = EVERYONE_MEMBER_ID val activeMember = vm.familyOverviewMembers .firstOrNull { it.id == activeMemberId } @@ -1225,7 +1215,7 @@ fun OnboardingHost( // the sheet should reflect ONLY what the currently selected member // (or Everyone) has chosen, not the union across all members. val activeMemberId = selectedAllergyMemberIdState.value - val activeMemberKey = if (activeMemberId.isBlank()) "ALL" else activeMemberId + val activeMemberKey = if (activeMemberId.isBlank()) EVERYONE_MEMBER_ID else activeMemberId // Sync activeMemberSelections whenever activeMemberKey or revision changes LaunchedEffect(activeMemberKey, allergySelectionRevision) { @@ -1256,8 +1246,8 @@ fun OnboardingHost( selectedMemberId = selectedAllergyMemberIdState.value, selectedAllergies = activeMemberSelections, onMemberSelected = { - val oldMemberKey = if (selectedAllergyMemberIdState.value.isBlank()) "ALL" else selectedAllergyMemberIdState.value - val newMemberKey = if (it.isBlank()) "ALL" else it + val oldMemberKey = if (selectedAllergyMemberIdState.value.isBlank()) EVERYONE_MEMBER_ID else selectedAllergyMemberIdState.value + val newMemberKey = if (it.isBlank()) EVERYONE_MEMBER_ID else it Log.d( "OnboardingAllergies", "[MEMBER SWITCH] from=$oldMemberKey to=$newMemberKey " + @@ -1269,7 +1259,7 @@ fun OnboardingHost( }, onToggleAllergy = { allergyId -> val activeMemberId = selectedAllergyMemberIdState.value - val memberKey = if (activeMemberId.isBlank()) "ALL" else activeMemberId + val memberKey = if (activeMemberId.isBlank()) EVERYONE_MEMBER_ID else activeMemberId Log.d( "OnboardingAllergies", @@ -1330,13 +1320,20 @@ fun OnboardingHost( if (allergyStepIndex < allergySteps.lastIndex) { allergyStepIndex++ } else { + // Sync dietary preferences to backend (same as iOS) before exiting + val preferenceText = buildDietaryPreferenceText(selectedAllergiesByMember) + Log.d("OnboardingAllergies", "[DietaryPreference] onNext complete: syncing textLength=${preferenceText.length}") + authViewModel.syncDietaryPreferencesFromOnboarding(preferenceText) onExitOnboarding() } } }, onSkipPreferences = { // User tapped "All Set!" on the fine‑tune decision screen: - // close the decision and exit the onboarding fine‑tune flow. + // sync preferences to backend (same as iOS) then close and exit. + val preferenceText = buildDietaryPreferenceText(selectedAllergiesByMember) + Log.d("OnboardingAllergies", "[DietaryPreference] onSkipPreferences: syncing textLength=${preferenceText.length}") + authViewModel.syncDietaryPreferencesFromOnboarding(preferenceText) showFineTuneDecision = false onExitOnboarding() }, diff --git a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingStepScreens.kt b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingStepScreens.kt index 53458db..3b16c31 100644 --- a/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingStepScreens.kt +++ b/app/src/main/java/lc/fungee/Ingredicheck/onboarding/ui/OnboardingStepScreens.kt @@ -11,39 +11,26 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.animation.core.* import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -53,43 +40,32 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImageContent import lc.fungee.Ingredicheck.R import lc.fungee.Ingredicheck.auth.MemojiGenState -import lc.fungee.Ingredicheck.memoji.FillingPipeLine import lc.fungee.Ingredicheck.ui.components.buttons.PrimaryButton import lc.fungee.Ingredicheck.ui.components.buttons.SecondaryButton -import lc.fungee.Ingredicheck.ui.components.buttons.primaryButtonEffect import lc.fungee.Ingredicheck.onboarding.data.OnboardingChipData +import lc.fungee.Ingredicheck.onboarding.data.avatarBackgroundColorForId import lc.fungee.Ingredicheck.onboarding.ui.components.AnimatedProgressLine import lc.fungee.Ingredicheck.memoji.AvatarCategoryTabs import lc.fungee.Ingredicheck.onboarding.ui.components.CapsuleStep @@ -101,24 +77,16 @@ import lc.fungee.Ingredicheck.ui.theme.Greyscale110 import lc.fungee.Ingredicheck.ui.theme.Greyscale150 import lc.fungee.Ingredicheck.ui.theme.Greyscale60 import lc.fungee.Ingredicheck.ui.theme.Greyscale40 -import lc.fungee.Ingredicheck.ui.theme.Greyscale10 import lc.fungee.Ingredicheck.ui.theme.Nunito import lc.fungee.Ingredicheck.ui.theme.Fail100 -import lc.fungee.Ingredicheck.ui.theme.Fail25 import lc.fungee.Ingredicheck.ui.theme.Greyscale100 import lc.fungee.Ingredicheck.ui.theme.Greyscale120 import lc.fungee.Ingredicheck.ui.theme.Greyscale30 -import lc.fungee.Ingredicheck.ui.theme.Greyscale70 import lc.fungee.Ingredicheck.ui.theme.Greyscale80 -import lc.fungee.Ingredicheck.ui.theme.Greyscale90 import lc.fungee.Ingredicheck.ui.theme.Manrope -import lc.fungee.Ingredicheck.ui.theme.Primary700 import lc.fungee.Ingredicheck.ui.theme.Primary800 -import lc.fungee.Ingredicheck.ui.theme.titleTextStyle -import lc.fungee.Ingredicheck.ui.theme.subtitleTextStyle import lc.fungee.Ingredicheck.ui.theme.sheetTitleTextStyle import lc.fungee.Ingredicheck.ui.theme.sheetSubtitleTextStyle -import lc.fungee.Ingredicheck.ui.theme.buttonIconSize import lc.fungee.Ingredicheck.memoji.avatarOptionsForCategory @Composable @@ -396,16 +364,7 @@ internal fun AddFamilyAvatarGeneratingSheet( // Determine the background color from the user's selected color category (index 5) val selectedColorId = selections[5] val avatarBackgroundColor = remember(selectedColorId) { - when (selectedColorId) { - "color_pastel_blue" -> Color(0xFFA5D8FF) - "color_warm_pink" -> Color(0xFFFFB3C1) - "color_soft_green" -> Color(0xFFB9FBC0) - "color_lavender" -> Color(0xFFE3B8FF) - "color_orange" -> Color(0xFFFFB74D) - "color_yellow" -> Color(0xFFFFE082) - "color_transparent" -> Color.Transparent - else -> Color.White - } + avatarBackgroundColorForId(selectedColorId) } Box( From 3287b5954058ea569d038ce20ea735e6138d18da Mon Sep 17 00:00:00 2001 From: Gaurav-eightinity01 Date: Fri, 20 Feb 2026 18:41:42 +0530 Subject: [PATCH 10/18] Add persistence and detailed logging for allergy selections - Add persistence methods in OnboardingPersistence for saving/restoring allergy selections state (selected chips per member, active member ID, step index) - Restore allergy selections on app restart and rebuild flat union set for UI display - Add comprehensive logging throughout persistence layer and OnboardingHost for debugging selection state issues - Fix issue where restored selections weren't being applied to UI immediately after restart - Clear allergy selections state when onboarding is reset Co-authored-by: Cursor --- .idea/deploymentTargetSelector.xml | 2 +- .../onboarding/data/OnboardingData.kt | 105 ++++++++++++--- .../onboarding/model/OnboardingPersistence.kt | 51 +++++++ .../onboarding/model/OnboardingViewModel.kt | 6 + .../onboarding/ui/AllergyScreens.kt | 102 ++++++++++++-- .../onboarding/ui/OnboardingAnimations.kt | 52 ++++++- .../onboarding/ui/OnboardingHost.kt | 127 +++++++++++++++--- .../onboarding/ui/OnboardingStepScreens.kt | 22 +-- app/src/main/res/drawable/ingredi_robo2.png | Bin 0 -> 177898 bytes 9 files changed, 392 insertions(+), 75 deletions(-) create mode 100644 app/src/main/res/drawable/ingredi_robo2.png diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 62599a1..b25a91b 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,7 +4,7 @@