From fce9023ed508a2f4ddb76f446f2535c88f786e4b Mon Sep 17 00:00:00 2001 From: Omar Amer Date: Thu, 9 Jul 2026 11:04:37 +0300 Subject: [PATCH 01/15] feat(data): implement survey recommendations pipeline Add the data layer support for survey-based product recommendations. This includes: - New DTOs for request/response serialization - Repository method to transform survey answers into recommendations - RemoteDataSource interface and stub implementation (pending backend endpoint) --- .../data/repository/TrovesRepositoryImpl.kt | 25 ++ .../data/source/remote/RemoteDatasource.kt | 6 + .../source/remote/RemoteDatasourceImpl.kt | 14 ++ .../remote/dto/SurveyRecommendationDto.kt | 33 +++ .../composeResources/values-ar/strings.xml | 2 + .../composeResources/values/strings.xml | 2 + .../com/troves/domain/di/DomainModule.kt | 3 + .../domain/entity/SurveyRecommendedItem.kt | 15 ++ .../domain/repository/TrovesRepository.kt | 3 + .../home/GetSurveyRecommendationsUseCase.kt | 20 ++ .../composeResources/values/strings.xml | 1 + .../presintation/ui/home/HomeContract.kt | 3 + .../troves/presintation/ui/home/HomeScreen.kt | 9 + .../presintation/ui/home/HomeViewModel.kt | 55 +++++ .../ui/home/components/YourTrovesSection.kt | 232 ++++++++++++++++++ 15 files changed, 423 insertions(+) create mode 100644 data/src/commonMain/kotlin/com/troves/data/source/remote/dto/SurveyRecommendationDto.kt create mode 100644 domain/src/commonMain/kotlin/com/troves/domain/entity/SurveyRecommendedItem.kt create mode 100644 domain/src/commonMain/kotlin/com/troves/domain/usecase/home/GetSurveyRecommendationsUseCase.kt create mode 100644 presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/components/YourTrovesSection.kt diff --git a/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt b/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt index bf7eee89..84be3663 100644 --- a/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt +++ b/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt @@ -141,6 +141,31 @@ class TrovesRepositoryImpl( } } + override suspend fun getSurveyRecommendations( + surveyAnswers: com.troves.domain.entity.SurveyAnswers, + ): Result> { + return withContext(coroutineDispatcher) { + val request = com.troves.data.source.remote.dto.SurveyRecommendationRequestDto( + categories = surveyAnswers.favoriteCategories, + preferredPriceRange = surveyAnswers.preferredPriceRange, + shoppingStyle = surveyAnswers.shoppingStyle, + gender = surveyAnswers.gender, + ) + remoteDataSource.getSurveyRecommendations(request).map { response -> + response.products.map { dto -> + com.troves.domain.entity.SurveyRecommendedItem( + id = dto.id, + title = dto.title, + vendor = dto.vendor, + imageUrl = dto.imageUrl, + price = dto.price, + status = dto.status, + ) + } + } + } + } + // ── Settings ──────────────────────────────────────────────────────────── override suspend fun getAds(): Result> = Result.Success(localAdsDataSource.getAds()) diff --git a/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasource.kt b/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasource.kt index 17259e29..4d389894 100644 --- a/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasource.kt +++ b/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasource.kt @@ -71,4 +71,10 @@ interface RemoteDatasource { //endregion suspend fun getDiscountCodes(): Result> + + //region survey recommendations + suspend fun getSurveyRecommendations( + request: com.troves.data.source.remote.dto.SurveyRecommendationRequestDto, + ): Result + //endregion } \ No newline at end of file diff --git a/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt b/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt index 6bad367c..54c919aa 100644 --- a/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt +++ b/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt @@ -174,6 +174,20 @@ class RemoteDatasourceImpl( } } + // ── Survey Recommendations ──────────────────────────────────────────────── + // TODO: Replace this stub with a real Ktor call once the backend endpoint + // URL is provided. The shape of SurveyRecommendationResponseDto is + // already defined and ready to deserialize the real response. + override suspend fun getSurveyRecommendations( + request: com.troves.data.source.remote.dto.SurveyRecommendationRequestDto, + ): Result { + return Result.Success( + com.troves.data.source.remote.dto.SurveyRecommendationResponseDto( + products = emptyList() // Real data comes once the endpoint is wired + ) + ) + } + private fun aiChatsCollection(userId: String) = firestore.collection("users").document(userId).collection("aiChats") diff --git a/data/src/commonMain/kotlin/com/troves/data/source/remote/dto/SurveyRecommendationDto.kt b/data/src/commonMain/kotlin/com/troves/data/source/remote/dto/SurveyRecommendationDto.kt new file mode 100644 index 00000000..91efd2da --- /dev/null +++ b/data/src/commonMain/kotlin/com/troves/data/source/remote/dto/SurveyRecommendationDto.kt @@ -0,0 +1,33 @@ +package com.troves.data.source.remote.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * DTO for the AI survey recommendation endpoint. + * The real endpoint URL will be provided later; the implementation in + * [RemoteDatasourceImpl.getSurveyRecommendations] currently returns a + * placeholder list so the full feature pipeline can be exercised end-to-end. + */ +@Serializable +data class SurveyRecommendationRequestDto( + val categories: List = emptyList(), + val preferredPriceRange: String = "", + val shoppingStyle: String = "", + val gender: String = "", +) + +@Serializable +data class SurveyRecommendationResponseDto( + val products: List = emptyList(), +) + +@Serializable +data class SurveyRecommendedProductDto( + val id: String = "", + val title: String = "", + val vendor: String = "", + @SerialName("image_url") val imageUrl: String? = null, + val price: String = "", + val status: String = "active", +) diff --git a/designSystem/src/commonMain/composeResources/values-ar/strings.xml b/designSystem/src/commonMain/composeResources/values-ar/strings.xml index bbb60dc9..d5467443 100644 --- a/designSystem/src/commonMain/composeResources/values-ar/strings.xml +++ b/designSystem/src/commonMain/composeResources/values-ar/strings.xml @@ -144,6 +144,8 @@ أفضل العلامات التجارية الأكثر رواجاً نسخ الكود + تروفز الخاصة بك + اختيارات الذكاء الاصطناعي لك ✦ الفئات diff --git a/designSystem/src/commonMain/composeResources/values/strings.xml b/designSystem/src/commonMain/composeResources/values/strings.xml index 95c4287a..110ae1fc 100644 --- a/designSystem/src/commonMain/composeResources/values/strings.xml +++ b/designSystem/src/commonMain/composeResources/values/strings.xml @@ -149,6 +149,8 @@ Top Brands Trending Now Copy code + Your Troves + Picked by AI just for you ✦ Categories diff --git a/domain/src/commonMain/kotlin/com/troves/domain/di/DomainModule.kt b/domain/src/commonMain/kotlin/com/troves/domain/di/DomainModule.kt index 7257902d..bdc035d4 100644 --- a/domain/src/commonMain/kotlin/com/troves/domain/di/DomainModule.kt +++ b/domain/src/commonMain/kotlin/com/troves/domain/di/DomainModule.kt @@ -29,6 +29,7 @@ import com.troves.domain.usecase.home.GetBrandsUseCase import com.troves.domain.usecase.home.GetCategoriesUseCase import com.troves.domain.usecase.home.GetDiscountCodesUseCase import com.troves.domain.usecase.home.GetJustForYouProductsUseCase +import com.troves.domain.usecase.home.GetSurveyRecommendationsUseCase import com.troves.domain.usecase.home.GetTrendingProductsUseCase import com.troves.domain.usecase.onboarding.CompleteOnboardingUseCase import com.troves.domain.usecase.onboarding.IsOnboardingDoneUseCase @@ -80,6 +81,8 @@ val domainModule = module { factory { FilterProductsByQueryUseCase(get()) } factory { SearchProductsUseCase(get()) } factory { GetDiscountCodesUseCase(get()) } + factory { GetSurveyRecommendationsUseCase(get()) } + // Onboarding factory { IsOnboardingDoneUseCase(get()) } diff --git a/domain/src/commonMain/kotlin/com/troves/domain/entity/SurveyRecommendedItem.kt b/domain/src/commonMain/kotlin/com/troves/domain/entity/SurveyRecommendedItem.kt new file mode 100644 index 00000000..43c5d357 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/troves/domain/entity/SurveyRecommendedItem.kt @@ -0,0 +1,15 @@ +package com.troves.domain.entity + +/** + * A product recommended by the AI based on the user's survey answers. + * Maps directly onto [Product] for display — separated here to carry + * the recommendation "reason" without polluting the core Product entity. + */ +data class SurveyRecommendedItem( + val id: String, + val title: String, + val vendor: String, + val imageUrl: String?, + val price: String, + val status: String = "active", +) diff --git a/domain/src/commonMain/kotlin/com/troves/domain/repository/TrovesRepository.kt b/domain/src/commonMain/kotlin/com/troves/domain/repository/TrovesRepository.kt index b4e1fd81..0ee6b221 100644 --- a/domain/src/commonMain/kotlin/com/troves/domain/repository/TrovesRepository.kt +++ b/domain/src/commonMain/kotlin/com/troves/domain/repository/TrovesRepository.kt @@ -11,6 +11,8 @@ import com.troves.domain.entity.OrderSummary import com.troves.domain.entity.Product import com.troves.domain.entity.ProductSearchParams import com.troves.domain.entity.Review +import com.troves.domain.entity.SurveyAnswers +import com.troves.domain.entity.SurveyRecommendedItem import com.troves.domain.utils.Result import kotlinx.coroutines.flow.Flow @@ -26,6 +28,7 @@ interface TrovesRepository { suspend fun getCategories(): Result> suspend fun getAds(): Result> suspend fun getDiscountCodes(): Result> + suspend fun getSurveyRecommendations(surveyAnswers: SurveyAnswers): Result> // ── Settings ──────────────────────────────────────────────────────────── val selectedLanguage: Flow diff --git a/domain/src/commonMain/kotlin/com/troves/domain/usecase/home/GetSurveyRecommendationsUseCase.kt b/domain/src/commonMain/kotlin/com/troves/domain/usecase/home/GetSurveyRecommendationsUseCase.kt new file mode 100644 index 00000000..d86d43d7 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/troves/domain/usecase/home/GetSurveyRecommendationsUseCase.kt @@ -0,0 +1,20 @@ +package com.troves.domain.usecase.home + +import com.troves.domain.entity.SurveyAnswers +import com.troves.domain.entity.SurveyRecommendedItem +import com.troves.domain.repository.TrovesRepository +import com.troves.domain.utils.Result + +/** + * Fetches AI-powered product recommendations based on the user's survey answers. + * + * The backing endpoint is a placeholder until the real backend URL is wired in. + * When it returns an empty list, the "Your Troves" section is simply hidden on + * the Home screen — no error state is shown. + */ +class GetSurveyRecommendationsUseCase( + private val repository: TrovesRepository, +) { + suspend operator fun invoke(surveyAnswers: SurveyAnswers): Result> = + repository.getSurveyRecommendations(surveyAnswers) +} diff --git a/presintation/src/commonMain/composeResources/values/strings.xml b/presintation/src/commonMain/composeResources/values/strings.xml index 8a6d25fb..79c4c306 100644 --- a/presintation/src/commonMain/composeResources/values/strings.xml +++ b/presintation/src/commonMain/composeResources/values/strings.xml @@ -317,6 +317,7 @@ Just For You Trending Now + Your Troves Copied %1$s to clipboard All Categories diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeContract.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeContract.kt index e5acb31a..11672e85 100644 --- a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeContract.kt +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeContract.kt @@ -21,6 +21,8 @@ data class HomeUiState( val isLoggedIn: Boolean = false, val cartItemCount: Int = 0, val isOffline: Boolean = false, + val yourTroves: List = emptyList(), + val isLoadingYourTroves: Boolean = false, ) { val hasError: Boolean get() = errorMessage != null @@ -57,6 +59,7 @@ sealed interface HomeIntent { data object ViewAllCategoriesClicked : HomeIntent data object ViewAllJustForYouClicked : HomeIntent data object ViewAllTrendingClicked : HomeIntent + data object ViewAllYourTrovesClicked : HomeIntent data object SearchClicked : HomeIntent data object CartClicked : HomeIntent data object AiClicked : HomeIntent diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeScreen.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeScreen.kt index 33f8fff1..24e196b2 100644 --- a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeScreen.kt +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeScreen.kt @@ -73,6 +73,8 @@ import com.troves.presintation.ui.home.components.AdData import com.troves.presintation.ui.home.components.AdSlider import com.troves.presintation.ui.home.components.BrandItem import com.troves.presintation.ui.home.components.CategoryItem +import com.troves.presintation.ui.home.components.YourTrovesSection + import com.troves.presintation.ui.survey.components.SurveyBannerCard import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource @@ -338,6 +340,13 @@ private fun HomeContent( ) } + YourTrovesSection( + products = state.yourTroves, + isLoading = state.isLoadingYourTroves, + favoriteIds = state.favoriteProductIds, + onIntent = onIntent, + ) + if (state.categories.isNotEmpty()) { SectionHeader( title = stringResource(Res.string.home_categories_title), diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeViewModel.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeViewModel.kt index e2098a25..946f24dd 100644 --- a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeViewModel.kt +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeViewModel.kt @@ -8,6 +8,7 @@ import com.troves.domain.usecase.home.GetAdsUseCase import com.troves.domain.usecase.home.GetBrandsUseCase import com.troves.domain.usecase.home.GetCategoriesUseCase import com.troves.domain.usecase.home.GetJustForYouProductsUseCase +import com.troves.domain.usecase.home.GetSurveyRecommendationsUseCase import com.troves.domain.usecase.home.GetTrendingProductsUseCase import com.troves.domain.usecase.shared.ObserveConnectivityUseCase import com.troves.domain.usecase.wishlist.GetWishlistUseCase @@ -36,6 +37,7 @@ import troves.presintation.generated.resources.favorites_update_failed import troves.presintation.generated.resources.home_copied_to_clipboard import troves.presintation.generated.resources.home_source_just_for_you import troves.presintation.generated.resources.home_source_trending_now +import troves.presintation.generated.resources.home_source_your_troves class HomeViewModel( @@ -52,6 +54,8 @@ class HomeViewModel( private val getCartStream: com.troves.domain.usecase.cart.GetCartStreamUseCase, private val refreshCart: com.troves.domain.usecase.cart.RefreshCartUseCase, private val observeConnectivity: ObserveConnectivityUseCase, + private val getSurveyRecommendations: GetSurveyRecommendationsUseCase, + private val authenticationRepository: com.troves.domain.repository.AuthenticationRepository, ) : ViewModel(), StateHolder by DefaultStateHolder(HomeUiState()), EffectPublisher by DefaultEffectPublisher() { @@ -123,6 +127,15 @@ class HomeViewModel( ), ) } + HomeIntent.ViewAllYourTrovesClicked -> viewModelScope.launch { + sendEffect( + NavigateToProducts( + sourceType = "collection", + sourceId = "your-troves", + sourceName = getString(Res.string.home_source_your_troves), + ), + ) + } is HomeIntent.AdClicked -> { val targetType = intent.ad.targetType val targetId = intent.ad.targetId @@ -226,6 +239,48 @@ class HomeViewModel( viewModelScope.launch { observeSurveyDone().collect { done -> updateState { copy(isSurveyDone = done) } + if (done) { + loadSurveyRecommendations() + } + } + } + } + + private fun loadSurveyRecommendations() { + viewModelScope.launch { + updateState { copy(isLoadingYourTroves = true) } + val profile = authenticationRepository.getCurrentUserProfile() + val userId = profile?.id + if (userId == null) { + updateState { copy(isLoadingYourTroves = false) } + return@launch + } + // Read the saved survey answers from Firestore via the auth repository + // then call the AI recommendations use case + val surveyAnswers = runCatching { + // Fetch from Firestore user document (userId already confirmed above) + (authenticationRepository as? com.troves.domain.repository.AuthenticationRepository) + ?.let { com.troves.domain.entity.SurveyAnswers() } // use defaults until real fetch is wired + }.getOrNull() ?: com.troves.domain.entity.SurveyAnswers() + + val result = getSurveyRecommendations(surveyAnswers) + val products = (result as? com.troves.domain.utils.Result.Success)?.value + ?.map { item -> + Product( + id = item.id.toLongOrNull() ?: 0L, + title = item.title, + vendor = item.vendor, + price = item.price, + imageUrl = item.imageUrl ?: "", + status = item.status, + ) + } ?: emptyList() + + updateState { + copy( + yourTroves = products, + isLoadingYourTroves = false, + ) } } } diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/components/YourTrovesSection.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/components/YourTrovesSection.kt new file mode 100644 index 00000000..a066e23b --- /dev/null +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/components/YourTrovesSection.kt @@ -0,0 +1,232 @@ +package com.troves.presintation.ui.home.components + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.rememberAsyncImagePainter +import com.troves.designsystem.components.cards.MainCard +import com.troves.designsystem.components.shimmer.shimmerEffect +import com.troves.designsystem.theme.Theme +import com.troves.designsystem.util.bounceClick +import com.troves.designsystem.util.formatPrice +import com.troves.designsystem.util.autoMirror +import com.troves.domain.entity.Product +import com.troves.presintation.ui.home.HomeIntent +import org.jetbrains.compose.resources.stringResource +import troves.designsystem.generated.resources.Res +import troves.designsystem.generated.resources.home_your_troves +import troves.designsystem.generated.resources.home_your_troves_subtitle +import troves.designsystem.generated.resources.see_all +import troves.designsystem.generated.resources.ic_chevron_right +import troves.designsystem.generated.resources.img_placeholder +import troves.designsystem.generated.resources.ic_solid_heart +import troves.designsystem.generated.resources.ic_star +import org.jetbrains.compose.resources.painterResource + +private const val PLACEHOLDER_RATING = 4.5 + +/** + * "Your Troves" AI-personalised recommendation section. + * + * - Shows a shimmer row while [isLoading] is true. + * - Shows the product row when [products] is non-empty. + * - Hidden entirely when neither loading nor has products. + */ +@Composable +fun YourTrovesSection( + products: List, + isLoading: Boolean, + favoriteIds: Set, + onIntent: (HomeIntent) -> Unit, + modifier: Modifier = Modifier, +) { + if (!isLoading && products.isEmpty()) return + + val placeholderPainter = painterResource(Res.drawable.img_placeholder) + val starIcon = painterResource(Res.drawable.ic_star) + val heartIcon = painterResource(Res.drawable.ic_solid_heart) + val chevron = painterResource(Res.drawable.ic_chevron_right) + + Column(modifier = modifier) { + YourTrovesSectionHeader( + chevronPainter = chevron, + onSeeAll = { onIntent(HomeIntent.ViewAllYourTrovesClicked) }, + ) + + if (isLoading) { + YourTrovesShimmer() + } else { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(products, key = { it.id }) { product -> + MainCard( + title = product.title, + price = formatPrice(product.price), + rating = PLACEHOLDER_RATING, + imagePainter = rememberAsyncImagePainter( + model = product.imageUrl, + placeholder = placeholderPainter, + error = placeholderPainter, + ), + ratingIconPainter = starIcon, + favoriteIconPainter = heartIcon, + isFavorite = product.id in favoriteIds, + onClick = { onIntent(HomeIntent.ProductClicked(product)) }, + onFavoriteClick = { onIntent(HomeIntent.FavoriteToggled(product)) }, + modifier = Modifier.width(170.dp), + ) + } + } + } + } +} + +@Composable +private fun YourTrovesSectionHeader( + chevronPainter: Painter, + onSeeAll: () -> Unit, +) { + // Subtle pulsing glow on the AI badge + val pulse by rememberInfiniteTransition(label = "ai_badge_pulse").animateFloat( + initialValue = 0.75f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "ai_badge_alpha", + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + BasicText( + text = stringResource(Res.string.home_your_troves), + style = Theme.typography.title.copy( + color = Theme.colors.primaryFont, + fontWeight = FontWeight.Bold, + ), + ) + // AI sparkle badge + Box( + modifier = Modifier + .graphicsLayer { alpha = pulse } + .clip(RoundedCornerShape(6.dp)) + .background( + Brush.linearGradient( + colors = listOf( + Theme.colors.primary, + Theme.colors.primary.copy(alpha = 0.6f), + ), + start = Offset(0f, 0f), + end = Offset(100f, 40f), + ) + ) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + BasicText( + text = "AI", + style = Theme.typography.body.small.copy( + color = Theme.colors.onPrimary, + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + ), + ) + } + } + BasicText( + text = stringResource(Res.string.home_your_troves_subtitle), + style = Theme.typography.body.small.copy( + color = Theme.colors.secondaryFont, + fontWeight = FontWeight.Normal, + ), + ) + } + + Spacer(Modifier.weight(1f)) + + Row( + modifier = Modifier.bounceClick( + shape = RoundedCornerShape(10.dp), + onClick = onSeeAll, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicText( + text = stringResource(Res.string.see_all), + style = Theme.typography.body.medium.copy( + color = Theme.colors.secondaryFont, + fontWeight = FontWeight.Medium, + ), + ) + androidx.compose.material3.Icon( + painter = chevronPainter, + contentDescription = null, + tint = Theme.colors.secondaryFont, + modifier = Modifier + .size(18.dp) + .autoMirror(), + ) + } + } +} + +@Composable +private fun YourTrovesShimmer() { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + repeat(3) { + Box( + modifier = Modifier + .width(170.dp) + .height(240.dp) + .clip(Theme.shapes.medium) + .shimmerEffect(), + ) + } + } +} From 969d06a35789bfcbaf8113044597ddde64b85eb6 Mon Sep 17 00:00:00 2001 From: Omar Amer Date: Thu, 9 Jul 2026 11:22:12 +0300 Subject: [PATCH 02/15] feat(data): integrate AI client for survey recommendations and update mapping - Inject named AI HttpClient into RemoteDatasourceImpl - Implement getSurveyRecommendations using Ktor POST to AI endpoint - Update SurveyRecommendationRequestDto to include full survey answers - Map product DTOs with new fields (featuredImage, price.amount, available) - Extract product ID from path and handle missing vendor field --- .../kotlin/com/troves/data/di/DataModule.kt | 2 +- .../data/repository/TrovesRepositoryImpl.kt | 25 ++++++++++------ .../source/remote/RemoteDatasourceImpl.kt | 29 ++++++++++++++----- .../remote/dto/SurveyRecommendationDto.kt | 23 ++++++++++----- 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/data/src/commonMain/kotlin/com/troves/data/di/DataModule.kt b/data/src/commonMain/kotlin/com/troves/data/di/DataModule.kt index 5e16cf6f..20c4e296 100644 --- a/data/src/commonMain/kotlin/com/troves/data/di/DataModule.kt +++ b/data/src/commonMain/kotlin/com/troves/data/di/DataModule.kt @@ -107,7 +107,7 @@ val dataModule = module { single { AiDataSourceImpl(get()) } // ── Remote data source ──────────────────────────────────────────────────── - single { RemoteDatasourceImpl(get(), get()) } + single { RemoteDatasourceImpl(get(), get(), get(named(AI_CLIENT))) } single { CurrencyRemoteDataSource(get(named(LOCATION_CLIENT))) } // ── Local ───────────────────────────────────────────────────────────────── diff --git a/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt b/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt index 84be3663..1c1ffe1f 100644 --- a/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt +++ b/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt @@ -146,20 +146,27 @@ class TrovesRepositoryImpl( ): Result> { return withContext(coroutineDispatcher) { val request = com.troves.data.source.remote.dto.SurveyRecommendationRequestDto( - categories = surveyAnswers.favoriteCategories, - preferredPriceRange = surveyAnswers.preferredPriceRange, - shoppingStyle = surveyAnswers.shoppingStyle, - gender = surveyAnswers.gender, + survey = com.troves.data.source.remote.dto.SurveyAnswersDto( + favoriteCategories = surveyAnswers.favoriteCategories, + favoriteBrands = surveyAnswers.favoriteBrands, + preferredPriceRange = surveyAnswers.preferredPriceRange, + shoppingStyle = surveyAnswers.shoppingStyle, + favoriteColors = surveyAnswers.favoriteColors, + gender = surveyAnswers.gender, + ageGroup = surveyAnswers.ageGroup, + shoppingFrequency = surveyAnswers.shoppingFrequency, + completed = true, + ) ) remoteDataSource.getSurveyRecommendations(request).map { response -> response.products.map { dto -> com.troves.domain.entity.SurveyRecommendedItem( - id = dto.id, + id = dto.id.split("/").lastOrNull() ?: dto.id, title = dto.title, - vendor = dto.vendor, - imageUrl = dto.imageUrl, - price = dto.price, - status = dto.status, + vendor = "", // API doesn't provide vendor currently + imageUrl = dto.featuredImage, + price = dto.price?.amount ?: "", + status = if (dto.available) "active" else "archived", ) } } diff --git a/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt b/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt index 54c919aa..b7933389 100644 --- a/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt +++ b/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt @@ -16,10 +16,18 @@ import com.troves.domain.entity.Product import com.troves.domain.entity.ProductSearchParams import com.troves.domain.utils.Result import dev.gitlive.firebase.firestore.FirebaseFirestore +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.contentType +import io.ktor.http.path class RemoteDatasourceImpl( private val trovesApiService: TrovesApiService, private val firestore: FirebaseFirestore, + private val aiClient: HttpClient, ) : RemoteDatasource { override suspend fun createProduct(productDto: ProductDto): Result { TODO("Not yet implemented") @@ -175,17 +183,22 @@ class RemoteDatasourceImpl( } // ── Survey Recommendations ──────────────────────────────────────────────── - // TODO: Replace this stub with a real Ktor call once the backend endpoint - // URL is provided. The shape of SurveyRecommendationResponseDto is - // already defined and ready to deserialize the real response. override suspend fun getSurveyRecommendations( request: com.troves.data.source.remote.dto.SurveyRecommendationRequestDto, ): Result { - return Result.Success( - com.troves.data.source.remote.dto.SurveyRecommendationResponseDto( - products = emptyList() // Real data comes once the endpoint is wired - ) - ) + return try { + val response = aiClient.post { + url { path("survey") } + contentType(io.ktor.http.ContentType.Application.Json) + setBody(request) + } + when (response.status) { + io.ktor.http.HttpStatusCode.OK -> Result.Success(response.body()) + else -> Result.Error(Throwable("${response.status}: ${response.bodyAsText()}")) + } + } catch (e: Exception) { + Result.Error(e) + } } private fun aiChatsCollection(userId: String) = diff --git a/data/src/commonMain/kotlin/com/troves/data/source/remote/dto/SurveyRecommendationDto.kt b/data/src/commonMain/kotlin/com/troves/data/source/remote/dto/SurveyRecommendationDto.kt index 91efd2da..254036f0 100644 --- a/data/src/commonMain/kotlin/com/troves/data/source/remote/dto/SurveyRecommendationDto.kt +++ b/data/src/commonMain/kotlin/com/troves/data/source/remote/dto/SurveyRecommendationDto.kt @@ -11,23 +11,30 @@ import kotlinx.serialization.Serializable */ @Serializable data class SurveyRecommendationRequestDto( - val categories: List = emptyList(), - val preferredPriceRange: String = "", - val shoppingStyle: String = "", - val gender: String = "", + val cartId: String? = null, + val survey: com.troves.data.source.remote.dto.SurveyAnswersDto? = null, ) @Serializable data class SurveyRecommendationResponseDto( + val reasoning: String = "", + val language: String = "en", + @SerialName("clarifying_question") val clarifyingQuestion: String? = null, + @SerialName("no_match") val noMatch: Boolean = false, + val suggested: Boolean = false, + val followups: List = emptyList(), val products: List = emptyList(), + val cartId: String? = null ) @Serializable data class SurveyRecommendedProductDto( val id: String = "", + val handle: String = "", val title: String = "", - val vendor: String = "", - @SerialName("image_url") val imageUrl: String? = null, - val price: String = "", - val status: String = "active", + val description: String = "", + val featuredImage: String? = null, + val price: com.troves.data.source.remote.ai.dto.AiPriceDto? = null, + val available: Boolean = true, + val why: String = "" ) From 66035e083c81a54f656ef94615acfd380ac8634e Mon Sep 17 00:00:00 2001 From: Omar Amer Date: Thu, 9 Jul 2026 11:41:39 +0300 Subject: [PATCH 03/15] feat(survey): add Reset intent to clear state on screen open - Add Reset data object to SurveyIntent sealed interface - Handle Reset in SurveyViewModel to reset currentStep, answers, and isSubmitting - Trigger Reset via LaunchedEffect when SurveyBottomSheet is launched This ensures the survey starts from a clean state each time the screen is opened, preventing stale data from previous sessions. --- .../com/troves/presintation/ui/survey/SurveyContract.kt | 1 + .../kotlin/com/troves/presintation/ui/survey/SurveyScreen.kt | 4 ++++ .../com/troves/presintation/ui/survey/SurveyViewModel.kt | 1 + 3 files changed, 6 insertions(+) diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyContract.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyContract.kt index 966c40ac..6bfa0aee 100644 --- a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyContract.kt +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyContract.kt @@ -78,6 +78,7 @@ sealed interface SurveyIntent { data class ToggleMultiOption(val option: String) : SurveyIntent data class SelectSingleOption(val option: String) : SurveyIntent data class ToggleColor(val colorName: String) : SurveyIntent + data object Reset : SurveyIntent } sealed interface SurveyEffect { diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyScreen.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyScreen.kt index ffa4e813..4f0437fe 100644 --- a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyScreen.kt +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyScreen.kt @@ -80,6 +80,10 @@ fun SurveyBottomSheet( val scope = rememberCoroutineScope() var errorMessage by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + viewModel.onIntent(SurveyIntent.Reset) + } + // After auto-advance on single-select, we auto-submit when we reach past last step fun advance() = viewModel.onIntent(SurveyIntent.NextStep) diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyViewModel.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyViewModel.kt index bb1672df..fa6a2704 100644 --- a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyViewModel.kt +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyViewModel.kt @@ -29,6 +29,7 @@ class SurveyViewModel( is SurveyIntent.ToggleMultiOption -> toggleMulti(intent.option) is SurveyIntent.SelectSingleOption -> selectSingle(intent.option) is SurveyIntent.ToggleColor -> toggleColor(intent.colorName) + SurveyIntent.Reset -> updateState { copy(currentStep = 0, answers = emptyMap(), isSubmitting = false) } } } From 2e0e82577ddcfd02495cb3584605d7fdde2a49b1 Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:21:40 +0300 Subject: [PATCH 04/15] fix: make SurveyAnswers.completedAt nullable to prevent AI endpoint rejection --- .../com/troves/data/source/remote/dto/UserProfileDto.kt | 4 ++-- .../kotlin/com/troves/domain/entity/SurveyAnswers.kt | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/data/src/commonMain/kotlin/com/troves/data/source/remote/dto/UserProfileDto.kt b/data/src/commonMain/kotlin/com/troves/data/source/remote/dto/UserProfileDto.kt index bda223e0..dec3bd5c 100644 --- a/data/src/commonMain/kotlin/com/troves/data/source/remote/dto/UserProfileDto.kt +++ b/data/src/commonMain/kotlin/com/troves/data/source/remote/dto/UserProfileDto.kt @@ -19,6 +19,6 @@ data class SurveyAnswersDto( val gender: String = "", val ageGroup: String = "", val shoppingFrequency: String = "", - val completed: Boolean = true, - val completedAt: String = "" + val completed: Boolean = false, + val completedAt: String? = null, ) diff --git a/domain/src/commonMain/kotlin/com/troves/domain/entity/SurveyAnswers.kt b/domain/src/commonMain/kotlin/com/troves/domain/entity/SurveyAnswers.kt index f91c398b..050dcfef 100644 --- a/domain/src/commonMain/kotlin/com/troves/domain/entity/SurveyAnswers.kt +++ b/domain/src/commonMain/kotlin/com/troves/domain/entity/SurveyAnswers.kt @@ -9,6 +9,8 @@ data class SurveyAnswers( val gender: String = "", val ageGroup: String = "", val shoppingFrequency: String = "", - val completed: Boolean = true, - val completedAt: String = "" + val completed: Boolean = false, + // Null when unknown. The AI `/survey` endpoint rejects a blank string with + // `validation_error: Invalid datetime`, but accepts the field being absent. + val completedAt: String? = null, ) From 3e62a5e782e0f4276e94ba9b019a236722104248 Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:21:47 +0300 Subject: [PATCH 05/15] add: SurveyAnswersDto.toDomain() reverse mapper and blank completedAt guard --- .../com/troves/data/mapper/SurveyMappers.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/data/src/commonMain/kotlin/com/troves/data/mapper/SurveyMappers.kt b/data/src/commonMain/kotlin/com/troves/data/mapper/SurveyMappers.kt index fb69eff4..26e14e58 100644 --- a/data/src/commonMain/kotlin/com/troves/data/mapper/SurveyMappers.kt +++ b/data/src/commonMain/kotlin/com/troves/data/mapper/SurveyMappers.kt @@ -14,6 +14,23 @@ fun SurveyAnswers.toDto(): SurveyAnswersDto { ageGroup = this.ageGroup, shoppingFrequency = this.shoppingFrequency, completed = this.completed, - completedAt = this.completedAt + completedAt = this.completedAt?.takeIf { it.isNotBlank() }, + ) +} + +fun SurveyAnswersDto.toDomain(): SurveyAnswers { + return SurveyAnswers( + favoriteCategories = this.favoriteCategories, + favoriteBrands = this.favoriteBrands, + preferredPriceRange = this.preferredPriceRange, + shoppingStyle = this.shoppingStyle, + favoriteColors = this.favoriteColors, + gender = this.gender, + ageGroup = this.ageGroup, + shoppingFrequency = this.shoppingFrequency, + completed = this.completed, + // Documents written before the timestamp fix carry "" here, which the + // AI endpoint rejects as an invalid datetime. + completedAt = this.completedAt?.takeIf { it.isNotBlank() }, ) } From cae850a011587648979fc934677be6ae6bb03502 Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:21:56 +0300 Subject: [PATCH 06/15] refactor: replace device-wide isSurveyDone flag with per-account dismissedUids set --- .../local/preferenceses/AppPreferencesDatasource.kt | 5 +++-- .../source/local/preferenceses/AppPreferencesKeys.kt | 6 +++++- .../local/preferenceses/TrovesPreferencesImpl.kt | 11 +++++++---- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/AppPreferencesDatasource.kt b/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/AppPreferencesDatasource.kt index 7979e70d..ff09c9c7 100644 --- a/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/AppPreferencesDatasource.kt +++ b/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/AppPreferencesDatasource.kt @@ -4,7 +4,8 @@ import kotlinx.coroutines.flow.Flow interface TrovesPreferences { val isOnboardingDone: Flow - val isSurveyDone: Flow + /** User ids that chose "never show the survey banner again" on this device. */ + val surveyBannerDismissedUids: Flow> val isCartHintShown: Flow val isLoggedIn: Flow val selectedLanguage: Flow @@ -33,7 +34,7 @@ interface TrovesPreferences { suspend fun setOnboardingDone(done: Boolean) - suspend fun setSurveyDone(done: Boolean) + suspend fun addSurveyBannerDismissedUid(userId: String) suspend fun setCartHintShown(shown: Boolean) suspend fun setLoggedIn(loggedIn: Boolean) suspend fun setSelectedLanguage(language: String) diff --git a/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/AppPreferencesKeys.kt b/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/AppPreferencesKeys.kt index 3c3ed8e1..c773e489 100644 --- a/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/AppPreferencesKeys.kt +++ b/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/AppPreferencesKeys.kt @@ -4,10 +4,14 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey internal object AppPreferencesKeys { val IS_ONBOARDING_DONE = booleanPreferencesKey("is_onboarding_done") - val IS_SURVEY_DONE = booleanPreferencesKey("is_survey_done") + // Whether the survey is *done* is per-account and lives in Firestore. Only the + // "never show the banner again" choice is local, and it is scoped by user id — + // a device-wide flag leaked one account's state onto the next. + val SURVEY_BANNER_DISMISSED_UIDS = stringSetPreferencesKey("survey_banner_dismissed_uids") val IS_CART_HINT_SHOWN = booleanPreferencesKey("is_cart_hint_shown") val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in") val SELECTED_LANGUAGE = stringPreferencesKey("selected_language") diff --git a/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/TrovesPreferencesImpl.kt b/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/TrovesPreferencesImpl.kt index 04b7607c..e13f2110 100644 --- a/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/TrovesPreferencesImpl.kt +++ b/data/src/commonMain/kotlin/com/troves/data/source/local/preferenceses/TrovesPreferencesImpl.kt @@ -69,10 +69,10 @@ class TrovesPreferencesImpl( .catchIOException() .map { it[AppPreferencesKeys.IS_ONBOARDING_DONE] ?: false } - override val isSurveyDone: Flow + override val surveyBannerDismissedUids: Flow> get() = dataStore.data .catchIOException() - .map { it[AppPreferencesKeys.IS_SURVEY_DONE] ?: false } + .map { it[AppPreferencesKeys.SURVEY_BANNER_DISMISSED_UIDS] ?: emptySet() } override val isCartHintShown: Flow get() = dataStore.data @@ -131,8 +131,11 @@ class TrovesPreferencesImpl( dataStore.edit { it[AppPreferencesKeys.IS_ONBOARDING_DONE] = done } } - override suspend fun setSurveyDone(done: Boolean) { - dataStore.edit { it[AppPreferencesKeys.IS_SURVEY_DONE] = done } + override suspend fun addSurveyBannerDismissedUid(userId: String) { + dataStore.edit { + val current = it[AppPreferencesKeys.SURVEY_BANNER_DISMISSED_UIDS] ?: emptySet() + it[AppPreferencesKeys.SURVEY_BANNER_DISMISSED_UIDS] = current + userId + } } override suspend fun setCartHintShown(shown: Boolean) { From 319c59f8a144f88ed5858957e58ffbb5316cd4af Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:22:04 +0300 Subject: [PATCH 07/15] add: getSurveyAnswers() from Firestore and fix productDocumentId to strip Shopify GID prefix --- .../data/source/remote/RemoteDatasource.kt | 1 + .../source/remote/RemoteDatasourceImpl.kt | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasource.kt b/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasource.kt index 4d389894..fc068d68 100644 --- a/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasource.kt +++ b/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasource.kt @@ -57,6 +57,7 @@ interface RemoteDatasource { suspend fun setUserCartId(userId: String, cartId: String) suspend fun clearUserCartId(userId: String) suspend fun saveSurveyAnswers(userId: String, answers: com.troves.data.source.remote.dto.SurveyAnswersDto): Result + suspend fun getSurveyAnswers(userId: String): com.troves.data.source.remote.dto.SurveyAnswersDto? //endregion //region aiChats diff --git a/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt b/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt index b7933389..51786bd4 100644 --- a/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt +++ b/data/src/commonMain/kotlin/com/troves/data/source/remote/RemoteDatasourceImpl.kt @@ -182,6 +182,19 @@ class RemoteDatasourceImpl( } } + override suspend fun getSurveyAnswers(userId: String): com.troves.data.source.remote.dto.SurveyAnswersDto? { + return try { + val snapshot = userDoc(userId).get() + if (snapshot.exists) { + snapshot.get("survey") + } else { + null + } + } catch (e: Exception) { + null + } + } + // ── Survey Recommendations ──────────────────────────────────────────────── override suspend fun getSurveyRecommendations( request: com.troves.data.source.remote.dto.SurveyRecommendationRequestDto, @@ -235,8 +248,12 @@ class RemoteDatasourceImpl( } } + /** Firestore document ids cannot contain '/', so strip the Shopify GID prefix. */ + private fun productDocumentId(productId: String): String = + productId.trimEnd('/').substringAfterLast('/').ifBlank { productId.replace("/", "_") } + private fun reviewsCollection(productId: String) = - firestore.collection("products").document(productId).collection("reviews") + firestore.collection("products").document(productDocumentId(productId)).collection("reviews") override suspend fun getProductReviews(productId: String): Result> { return try { From ec32fcceaf790ff0e85d5e3599b54b18f65899c9 Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:22:13 +0300 Subject: [PATCH 08/15] refactor: scope isSurveyDoneStream to account by reading Firestore instead of local pref --- .../AuthenticationRepositoryImpl.kt | 47 +++++++++++++++---- .../repository/AuthenticationRepository.kt | 7 ++- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/data/src/commonMain/kotlin/com/troves/data/repository/AuthenticationRepositoryImpl.kt b/data/src/commonMain/kotlin/com/troves/data/repository/AuthenticationRepositoryImpl.kt index 684fb350..f93b329c 100644 --- a/data/src/commonMain/kotlin/com/troves/data/repository/AuthenticationRepositoryImpl.kt +++ b/data/src/commonMain/kotlin/com/troves/data/repository/AuthenticationRepositoryImpl.kt @@ -14,11 +14,13 @@ import dev.gitlive.firebase.auth.GoogleAuthProvider import dev.gitlive.firebase.auth.auth import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import com.troves.data.source.remote.RemoteDatasource import com.troves.data.mapper.toDto +import com.troves.data.mapper.toDomain import com.troves.domain.entity.SurveyAnswers interface PlatformAuthenticationRepository : AuthenticationRepository @@ -139,27 +141,56 @@ class AuthenticationRepositoryFirebaseImpl( preferences.setOnboardingDone(true) } - override val isSurveyDoneStream: Flow = preferences.isSurveyDone + // Re-emits after the survey is saved, so the stream reflects the write without + // waiting for an auth event. + private val surveyRefresh = kotlinx.coroutines.flow.MutableStateFlow(0) + + // Survey completion is a property of the *account*, read from Firestore — never a + // device-wide flag, or signing into a second account inherits the first one's state. + // Declared after currentUserStream: property initializers run top-to-bottom. + // Each input is deduped: DataStore re-emits the whole preference set on any write, + // and idTokenChanged fires on every token refresh — neither should re-hit Firestore. + // The result is deliberately *not* deduped, so saving a survey reloads the + // recommendations even when the flag was already true (banner dismissed earlier). + override val isSurveyDoneStream: Flow = + combine( + currentUserStream.map { it?.id }.distinctUntilChanged(), + preferences.surveyBannerDismissedUids.distinctUntilChanged(), + surveyRefresh, + ) { userId, dismissedUids, _ -> userId to dismissedUids } + .map { (userId, dismissedUids) -> + when { + userId == null -> false + userId in dismissedUids -> true + else -> runCatching { remoteDatasource.getSurveyAnswers(userId) } + .getOrNull()?.completed == true + } + } - override suspend fun isSurveyDone(): Boolean = - preferences.isSurveyDone.first() + override suspend fun isSurveyDone(): Boolean = isSurveyDoneStream.first() - override suspend fun setSurveyDone() { - preferences.setSurveyDone(true) + override suspend fun dismissSurveyBanner() { + val userId = getCurrentUserId() ?: return + preferences.addSurveyBannerDismissedUid(userId) } override suspend fun saveSurveyAnswers(answers: SurveyAnswers): Result { val userId = getCurrentUserId() ?: return Result.Error(Exception("User not logged in")) - + val dto = answers.toDto() val remoteResult = remoteDatasource.saveSurveyAnswers(userId, dto) - + if (remoteResult is Result.Success) { - setSurveyDone() + surveyRefresh.value++ } return remoteResult } + override suspend fun getSurveyAnswers(): SurveyAnswers? { + val userId = getCurrentUserId() ?: return null + return remoteDatasource.getSurveyAnswers(userId)?.toDomain() + } + override fun getCurrentUserId(): String? = firebaseAuth.currentUser?.uid override fun getCurrentUserEmail(): String? = firebaseAuth.currentUser?.email diff --git a/domain/src/commonMain/kotlin/com/troves/domain/repository/AuthenticationRepository.kt b/domain/src/commonMain/kotlin/com/troves/domain/repository/AuthenticationRepository.kt index ec2e33c1..aec661df 100644 --- a/domain/src/commonMain/kotlin/com/troves/domain/repository/AuthenticationRepository.kt +++ b/domain/src/commonMain/kotlin/com/troves/domain/repository/AuthenticationRepository.kt @@ -25,14 +25,19 @@ interface AuthenticationRepository { suspend fun setOnboardingDone() + /** Per-account: true when the signed-in user completed the survey, or dismissed its banner. */ val isSurveyDoneStream: Flow suspend fun isSurveyDone(): Boolean - suspend fun setSurveyDone() + /** Suppresses the banner for the signed-in user without writing a survey document. */ + suspend fun dismissSurveyBanner() suspend fun saveSurveyAnswers(answers: com.troves.domain.entity.SurveyAnswers): Result + /** The survey saved on the user's Firestore document, or null if they haven't taken it. */ + suspend fun getSurveyAnswers(): com.troves.domain.entity.SurveyAnswers? + fun getCurrentUserId(): String? fun getCurrentUserEmail(): String? From 8d1ec0c02bfd05c3b4f50136e4c23c904f3c9a5d Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:22:21 +0300 Subject: [PATCH 09/15] refactor: fetch saved survey from Firestore inside getSurveyRecommendations instead of passing it as param --- .../data/repository/TrovesRepositoryImpl.kt | 30 +++++++++---------- .../domain/repository/TrovesRepository.kt | 3 +- .../home/GetSurveyRecommendationsUseCase.kt | 12 ++++---- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt b/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt index 1c1ffe1f..7e8dfe3d 100644 --- a/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt +++ b/data/src/commonMain/kotlin/com/troves/data/repository/TrovesRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.troves.data.repository import com.troves.data.mapper.toBrand import com.troves.data.mapper.toCategory import com.troves.data.mapper.toDomain +import com.troves.data.mapper.toDto import com.troves.data.source.local.preferenceses.TrovesPreferences import com.troves.data.source.remote.RemoteDatasource import com.troves.data.source.remote.dto.ReviewDto @@ -141,27 +142,24 @@ class TrovesRepositoryImpl( } } - override suspend fun getSurveyRecommendations( - surveyAnswers: com.troves.domain.entity.SurveyAnswers, - ): Result> { + override suspend fun getSurveyRecommendations(): Result> { return withContext(coroutineDispatcher) { + // No saved survey means nothing to personalise on; the endpoint would + // return zero products anyway. + val surveyAnswers = authenticationRepository.getSurveyAnswers() + ?: return@withContext Result.Success(emptyList()) + val request = com.troves.data.source.remote.dto.SurveyRecommendationRequestDto( - survey = com.troves.data.source.remote.dto.SurveyAnswersDto( - favoriteCategories = surveyAnswers.favoriteCategories, - favoriteBrands = surveyAnswers.favoriteBrands, - preferredPriceRange = surveyAnswers.preferredPriceRange, - shoppingStyle = surveyAnswers.shoppingStyle, - favoriteColors = surveyAnswers.favoriteColors, - gender = surveyAnswers.gender, - ageGroup = surveyAnswers.ageGroup, - shoppingFrequency = surveyAnswers.shoppingFrequency, - completed = true, - ) + cartId = resolveCartId(), + survey = surveyAnswers.toDto(), ) remoteDataSource.getSurveyRecommendations(request).map { response -> - response.products.map { dto -> + response.products.mapNotNull { dto -> + // "gid://shopify/Product/10285325648154" → "10285325648154" + val id = dto.id.substringAfterLast('/').takeIf { it.isNotBlank() } + ?: return@mapNotNull null com.troves.domain.entity.SurveyRecommendedItem( - id = dto.id.split("/").lastOrNull() ?: dto.id, + id = id, title = dto.title, vendor = "", // API doesn't provide vendor currently imageUrl = dto.featuredImage, diff --git a/domain/src/commonMain/kotlin/com/troves/domain/repository/TrovesRepository.kt b/domain/src/commonMain/kotlin/com/troves/domain/repository/TrovesRepository.kt index 0ee6b221..797216b7 100644 --- a/domain/src/commonMain/kotlin/com/troves/domain/repository/TrovesRepository.kt +++ b/domain/src/commonMain/kotlin/com/troves/domain/repository/TrovesRepository.kt @@ -11,7 +11,6 @@ import com.troves.domain.entity.OrderSummary import com.troves.domain.entity.Product import com.troves.domain.entity.ProductSearchParams import com.troves.domain.entity.Review -import com.troves.domain.entity.SurveyAnswers import com.troves.domain.entity.SurveyRecommendedItem import com.troves.domain.utils.Result import kotlinx.coroutines.flow.Flow @@ -28,7 +27,7 @@ interface TrovesRepository { suspend fun getCategories(): Result> suspend fun getAds(): Result> suspend fun getDiscountCodes(): Result> - suspend fun getSurveyRecommendations(surveyAnswers: SurveyAnswers): Result> + suspend fun getSurveyRecommendations(): Result> // ── Settings ──────────────────────────────────────────────────────────── val selectedLanguage: Flow diff --git a/domain/src/commonMain/kotlin/com/troves/domain/usecase/home/GetSurveyRecommendationsUseCase.kt b/domain/src/commonMain/kotlin/com/troves/domain/usecase/home/GetSurveyRecommendationsUseCase.kt index d86d43d7..0f56ac00 100644 --- a/domain/src/commonMain/kotlin/com/troves/domain/usecase/home/GetSurveyRecommendationsUseCase.kt +++ b/domain/src/commonMain/kotlin/com/troves/domain/usecase/home/GetSurveyRecommendationsUseCase.kt @@ -1,20 +1,18 @@ package com.troves.domain.usecase.home -import com.troves.domain.entity.SurveyAnswers import com.troves.domain.entity.SurveyRecommendedItem import com.troves.domain.repository.TrovesRepository import com.troves.domain.utils.Result /** - * Fetches AI-powered product recommendations based on the user's survey answers. + * Fetches AI-powered product recommendations from the user's saved survey. * - * The backing endpoint is a placeholder until the real backend URL is wired in. - * When it returns an empty list, the "Your Troves" section is simply hidden on - * the Home screen — no error state is shown. + * Returns an empty list when the user hasn't taken the survey, in which case the + * "Your Troves" section is simply hidden on the Home screen — no error state. */ class GetSurveyRecommendationsUseCase( private val repository: TrovesRepository, ) { - suspend operator fun invoke(surveyAnswers: SurveyAnswers): Result> = - repository.getSurveyRecommendations(surveyAnswers) + suspend operator fun invoke(): Result> = + repository.getSurveyRecommendations() } From 7a3f91c832be7336f07cb5646d82720dd49af9c2 Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:22:31 +0300 Subject: [PATCH 10/15] add: DismissSurveyBannerUseCase and register it in DomainModule --- .../kotlin/com/troves/domain/di/DomainModule.kt | 1 + .../usecase/survey/DismissSurveyBannerUseCase.kt | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 domain/src/commonMain/kotlin/com/troves/domain/usecase/survey/DismissSurveyBannerUseCase.kt diff --git a/domain/src/commonMain/kotlin/com/troves/domain/di/DomainModule.kt b/domain/src/commonMain/kotlin/com/troves/domain/di/DomainModule.kt index bdc035d4..107f0a10 100644 --- a/domain/src/commonMain/kotlin/com/troves/domain/di/DomainModule.kt +++ b/domain/src/commonMain/kotlin/com/troves/domain/di/DomainModule.kt @@ -92,6 +92,7 @@ val domainModule = module { factory { com.troves.domain.usecase.survey.IsSurveyDoneUseCase(get()) } factory { com.troves.domain.usecase.survey.CompleteSurveyUseCase(get()) } factory { com.troves.domain.usecase.survey.ObserveSurveyDoneUseCase(get()) } + factory { com.troves.domain.usecase.survey.DismissSurveyBannerUseCase(get()) } // Wishlist diff --git a/domain/src/commonMain/kotlin/com/troves/domain/usecase/survey/DismissSurveyBannerUseCase.kt b/domain/src/commonMain/kotlin/com/troves/domain/usecase/survey/DismissSurveyBannerUseCase.kt new file mode 100644 index 00000000..f45d6efa --- /dev/null +++ b/domain/src/commonMain/kotlin/com/troves/domain/usecase/survey/DismissSurveyBannerUseCase.kt @@ -0,0 +1,16 @@ +package com.troves.domain.usecase.survey + +import com.troves.domain.repository.AuthenticationRepository + +/** + * Stops the survey banner from reappearing for the signed-in user. + * + * Distinct from [CompleteSurveyUseCase]: dismissing the banner must not write a + * survey document, or it would overwrite answers the user gave earlier. The + * choice is scoped per account, so it does not follow the device. + */ +class DismissSurveyBannerUseCase( + private val repository: AuthenticationRepository, +) { + suspend operator fun invoke() = repository.dismissSurveyBanner() +} From 5b0803ec2c1b0038983f332b22f0c66820d86b40 Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:22:40 +0300 Subject: [PATCH 11/15] refactor: key survey answers by SurveyKey enum so reordering steps does not corrupt answers --- .../presintation/ui/survey/SurveyContract.kt | 14 +++++ .../presintation/ui/survey/SurveyMapper.kt | 56 ++++++++++--------- .../presintation/ui/survey/SurveyViewModel.kt | 6 +- 3 files changed, 49 insertions(+), 27 deletions(-) diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyContract.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyContract.kt index 6bfa0aee..9be2c6a9 100644 --- a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyContract.kt +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyContract.kt @@ -18,29 +18,39 @@ data class SurveyUiState( val isCompleted: Boolean get() = totalSteps > 0 && currentStep >= totalSteps } +/** Identifies what a question asks, so answers survive reordering the steps. */ +enum class SurveyKey { + Categories, Brands, PriceRange, Style, Colors, Gender, AgeGroup, ShoppingFrequency +} + sealed interface SurveyQuestion { + val key: SurveyKey val title: StringResource val subtitle: StringResource? data class MultiChip( + override val key: SurveyKey, override val title: StringResource, override val subtitle: StringResource? = null, val options: List, ) : SurveyQuestion data class SingleChip( + override val key: SurveyKey, override val title: StringResource, override val subtitle: StringResource? = null, val options: List, ) : SurveyQuestion data class StyleCards( + override val key: SurveyKey, override val title: StringResource, override val subtitle: StringResource? = null, val options: List, ) : SurveyQuestion data class ColorPicker( + override val key: SurveyKey, override val title: StringResource, override val subtitle: StringResource? = null, val colors: List, @@ -90,18 +100,21 @@ sealed interface SurveyEffect { fun buildSurveyQuestions(): List = listOf( // Step 0 — Categories SurveyQuestion.MultiChip( + key = SurveyKey.Categories, title = Res.string.survey_categories_title, subtitle = Res.string.survey_categories_subtitle, options = listOf(SurveyOption("Shoes", Res.string.survey_opt_shoes), SurveyOption("T-Shirts", Res.string.survey_opt_tshirts), SurveyOption("Hoodies", Res.string.survey_opt_hoodies), SurveyOption("Jackets", Res.string.survey_opt_jackets), SurveyOption("Accessories", Res.string.survey_opt_accessories), SurveyOption("Pants", Res.string.survey_opt_pants), SurveyOption("Dresses", Res.string.survey_opt_dresses), SurveyOption("Bags", Res.string.survey_opt_bags)), ), // Step 1 — Price Range SurveyQuestion.SingleChip( + key = SurveyKey.PriceRange, title = Res.string.survey_budget_title, subtitle = Res.string.survey_budget_subtitle, options = listOf(SurveyOption("Budget", Res.string.survey_opt_budget), SurveyOption("Mid-range", Res.string.survey_opt_midrange), SurveyOption("Premium", Res.string.survey_opt_premium), SurveyOption("Luxury", Res.string.survey_opt_luxury)), ), // Step 2 — Style SurveyQuestion.StyleCards( + key = SurveyKey.Style, title = Res.string.survey_style_title, subtitle = Res.string.survey_style_subtitle, options = listOf( @@ -115,6 +128,7 @@ fun buildSurveyQuestions(): List = listOf( ), // Step 3 — Gender SurveyQuestion.SingleChip( + key = SurveyKey.Gender, title = Res.string.survey_gender_title, subtitle = null, options = listOf(SurveyOption("Men", Res.string.survey_opt_men), SurveyOption("Women", Res.string.survey_opt_women)), diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyMapper.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyMapper.kt index 2360c6b2..e1d78d79 100644 --- a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyMapper.kt +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyMapper.kt @@ -2,35 +2,39 @@ package com.troves.presintation.ui.survey import com.troves.domain.entity.SurveyAnswers -fun Map.toDomainEntity(): SurveyAnswers { - // Assuming the structure matches SurveyViewModel.buildQuestions() - // 0: Category (MultiChip) - // 1: Brands (MultiChip) - // 2: Price Range (SingleChip) - // 3: Shopping Style (StyleCards) - // 4: Colors (ColorPicker) - // 5: Gender (SingleChip) - // 6: Age Group (SingleChip) - // 7: Shopping Frequency (SingleChip) +/** + * Answers are stored keyed by step index, so they must be resolved through + * [questions] rather than by hardcoded positions — otherwise reordering or + * adding a question silently maps answers onto the wrong fields. + */ +fun Map.toDomainEntity( + questions: List, + completedAt: String, +): SurveyAnswers { + fun answerFor(key: SurveyKey): SurveyAnswer? = + questions.indexOfFirst { it.key == key } + .takeIf { it >= 0 } + ?.let { this[it] } - val favoriteCategories = (this[0] as? SurveyAnswer.MultiSelection)?.selected?.toList() ?: emptyList() - val favoriteBrands = (this[1] as? SurveyAnswer.MultiSelection)?.selected?.toList() ?: emptyList() - val preferredPriceRange = (this[2] as? SurveyAnswer.SingleSelection)?.selected ?: "" - val shoppingStyle = (this[3] as? SurveyAnswer.SingleSelection)?.selected ?: "" - val favoriteColors = (this[4] as? SurveyAnswer.ColorSelection)?.selected?.toList() ?: emptyList() - val gender = (this[5] as? SurveyAnswer.SingleSelection)?.selected ?: "" - val ageGroup = (this[6] as? SurveyAnswer.SingleSelection)?.selected ?: "" - val shoppingFrequency = (this[7] as? SurveyAnswer.SingleSelection)?.selected ?: "" + fun multi(key: SurveyKey): List = when (val answer = answerFor(key)) { + is SurveyAnswer.MultiSelection -> answer.selected.toList() + is SurveyAnswer.ColorSelection -> answer.selected.toList() + else -> emptyList() + } + + fun single(key: SurveyKey): String = + (answerFor(key) as? SurveyAnswer.SingleSelection)?.selected.orEmpty() return SurveyAnswers( - favoriteCategories = favoriteCategories, - favoriteBrands = favoriteBrands, - preferredPriceRange = preferredPriceRange, - shoppingStyle = shoppingStyle, - favoriteColors = favoriteColors, - gender = gender, - ageGroup = ageGroup, - shoppingFrequency = shoppingFrequency, + favoriteCategories = multi(SurveyKey.Categories), + favoriteBrands = multi(SurveyKey.Brands), + preferredPriceRange = single(SurveyKey.PriceRange), + shoppingStyle = single(SurveyKey.Style), + favoriteColors = multi(SurveyKey.Colors), + gender = single(SurveyKey.Gender), + ageGroup = single(SurveyKey.AgeGroup), + shoppingFrequency = single(SurveyKey.ShoppingFrequency), completed = true, + completedAt = completedAt, ) } diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyViewModel.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyViewModel.kt index fa6a2704..76ed41b9 100644 --- a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyViewModel.kt +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/survey/SurveyViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.troves.domain.usecase.survey.CompleteSurveyUseCase import com.troves.domain.usecase.survey.IsSurveyDoneUseCase +import com.troves.presintation.core.time.nowIso8601 import com.troves.presintation.core.mvi.DefaultEffectPublisher import com.troves.presintation.core.mvi.DefaultStateHolder import com.troves.presintation.core.mvi.EffectPublisher @@ -80,7 +81,10 @@ class SurveyViewModel( private fun submit() { viewModelScope.launch { updateState { copy(isSubmitting = true) } - val answersEntity = currentState.answers.toDomainEntity() + val answersEntity = currentState.answers.toDomainEntity( + questions = currentState.questions, + completedAt = nowIso8601(), + ) val result = completeSurvey(answersEntity) updateState { copy(isSubmitting = false) } From 18067004f31138ffc4185b6f7e823b5d9c1ec874 Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:22:48 +0300 Subject: [PATCH 12/15] add: nowIso8601() expect/actual utility for stamping survey completedAt on each platform --- .../troves/presintation/core/time/Iso8601.android.kt | 12 ++++++++++++ .../com/troves/presintation/core/time/Iso8601.kt | 4 ++++ .../com/troves/presintation/core/time/Iso8601.ios.kt | 6 ++++++ 3 files changed, 22 insertions(+) create mode 100644 presintation/src/androidMain/kotlin/com/troves/presintation/core/time/Iso8601.android.kt create mode 100644 presintation/src/commonMain/kotlin/com/troves/presintation/core/time/Iso8601.kt create mode 100644 presintation/src/iosMain/kotlin/com/troves/presintation/core/time/Iso8601.ios.kt diff --git a/presintation/src/androidMain/kotlin/com/troves/presintation/core/time/Iso8601.android.kt b/presintation/src/androidMain/kotlin/com/troves/presintation/core/time/Iso8601.android.kt new file mode 100644 index 00000000..b0ed66d7 --- /dev/null +++ b/presintation/src/androidMain/kotlin/com/troves/presintation/core/time/Iso8601.android.kt @@ -0,0 +1,12 @@ +package com.troves.presintation.core.time + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +// java.time needs API 26 (minSdk is 24) and core-library desugaring is not enabled. +actual fun nowIso8601(): String = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + .format(Date()) diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/core/time/Iso8601.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/core/time/Iso8601.kt new file mode 100644 index 00000000..e77011dd --- /dev/null +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/core/time/Iso8601.kt @@ -0,0 +1,4 @@ +package com.troves.presintation.core.time + +/** Current UTC instant as an ISO-8601 string, e.g. `2026-07-09T10:24:00Z`. */ +expect fun nowIso8601(): String diff --git a/presintation/src/iosMain/kotlin/com/troves/presintation/core/time/Iso8601.ios.kt b/presintation/src/iosMain/kotlin/com/troves/presintation/core/time/Iso8601.ios.kt new file mode 100644 index 00000000..24073f5d --- /dev/null +++ b/presintation/src/iosMain/kotlin/com/troves/presintation/core/time/Iso8601.ios.kt @@ -0,0 +1,6 @@ +package com.troves.presintation.core.time + +import platform.Foundation.NSDate +import platform.Foundation.NSISO8601DateFormatter + +actual fun nowIso8601(): String = NSISO8601DateFormatter().stringFromDate(NSDate()) From 1c4268f98cb0fdd3aa8dc738e5318d1cd7849e75 Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:22:57 +0300 Subject: [PATCH 13/15] fix: reload Your Troves on account switch and use DismissSurveyBannerUseCase instead of CompleteSurveyUseCase --- .../presintation/ui/home/HomeViewModel.kt | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeViewModel.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeViewModel.kt index 946f24dd..d0347da1 100644 --- a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeViewModel.kt +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/home/HomeViewModel.kt @@ -3,6 +3,7 @@ package com.troves.presintation.ui.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.troves.domain.entity.Product +import com.troves.domain.entity.SurveyRecommendedItem import com.troves.domain.usecase.auth.IsLoggedInUseCase import com.troves.domain.usecase.home.GetAdsUseCase import com.troves.domain.usecase.home.GetBrandsUseCase @@ -27,6 +28,7 @@ import com.troves.presintation.core.mvi.DefaultStateHolder import com.troves.presintation.core.mvi.EffectPublisher import com.troves.presintation.core.mvi.StateHolder import com.troves.presintation.ui.home.HomeEffect.* +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString @@ -50,12 +52,11 @@ class HomeViewModel( private val getWishlist: GetWishlistUseCase, private val toggleFavoriteUseCase: ToggleFavoriteUseCase, private val observeSurveyDone: com.troves.domain.usecase.survey.ObserveSurveyDoneUseCase, - private val completeSurvey: com.troves.domain.usecase.survey.CompleteSurveyUseCase, + private val dismissSurveyBanner: com.troves.domain.usecase.survey.DismissSurveyBannerUseCase, private val getCartStream: com.troves.domain.usecase.cart.GetCartStreamUseCase, private val refreshCart: com.troves.domain.usecase.cart.RefreshCartUseCase, private val observeConnectivity: ObserveConnectivityUseCase, private val getSurveyRecommendations: GetSurveyRecommendationsUseCase, - private val authenticationRepository: com.troves.domain.repository.AuthenticationRepository, ) : ViewModel(), StateHolder by DefaultStateHolder(HomeUiState()), EffectPublisher by DefaultEffectPublisher() { @@ -235,46 +236,33 @@ class HomeViewModel( } } + private var yourTrovesJob: Job? = null + private fun loadSurveyStatus() { - viewModelScope.launch { - observeSurveyDone().collect { done -> + // Not deduped here: the source only emits on account switch, banner dismissal, + // or a fresh survey save — each of which should re-resolve the section. + observeSurveyDone() + .onEach { done -> updateState { copy(isSurveyDone = done) } if (done) { loadSurveyRecommendations() + } else { + // Signed out, or a different account signed in: drop the previous + // user's recommendations instead of leaving them on screen. + yourTrovesJob?.cancel() + updateState { copy(yourTroves = emptyList(), isLoadingYourTroves = false) } } } - } + .launchIn(viewModelScope) } private fun loadSurveyRecommendations() { - viewModelScope.launch { + yourTrovesJob?.cancel() + yourTrovesJob = viewModelScope.launch { updateState { copy(isLoadingYourTroves = true) } - val profile = authenticationRepository.getCurrentUserProfile() - val userId = profile?.id - if (userId == null) { - updateState { copy(isLoadingYourTroves = false) } - return@launch - } - // Read the saved survey answers from Firestore via the auth repository - // then call the AI recommendations use case - val surveyAnswers = runCatching { - // Fetch from Firestore user document (userId already confirmed above) - (authenticationRepository as? com.troves.domain.repository.AuthenticationRepository) - ?.let { com.troves.domain.entity.SurveyAnswers() } // use defaults until real fetch is wired - }.getOrNull() ?: com.troves.domain.entity.SurveyAnswers() - - val result = getSurveyRecommendations(surveyAnswers) - val products = (result as? com.troves.domain.utils.Result.Success)?.value - ?.map { item -> - Product( - id = item.id.toLongOrNull() ?: 0L, - title = item.title, - vendor = item.vendor, - price = item.price, - imageUrl = item.imageUrl ?: "", - status = item.status, - ) - } ?: emptyList() + val products = getSurveyRecommendations() + .getOrElse(emptyList()) + .mapNotNull { it.toProduct() } updateState { copy( @@ -285,10 +273,24 @@ class HomeViewModel( } } + /** Drops recommendations whose Shopify id isn't numeric — [Product.id] is a Long, and + * collapsing them all to 0L would duplicate keys in the LazyRow. */ + private fun SurveyRecommendedItem.toProduct(): Product? { + val numericId = id.toLongOrNull() ?: return null + return Product( + id = numericId, + title = title, + vendor = vendor, + price = price, + imageUrl = imageUrl ?: "", + status = status, + ) + } + private fun dismissSurveyPermanently() { viewModelScope.launch { updateState { copy(isSurveyDone = true) } - completeSurvey(com.troves.domain.entity.SurveyAnswers()) + dismissSurveyBanner() } } From 1548e4d88a548a5d27d91f3537eb4228763f7a79 Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:23:13 +0300 Subject: [PATCH 14/15] fix: unescape apostrophes and newlines in strings.xml and fix onboarding skip button color --- .../commonMain/composeResources/values/strings.xml | 14 +++++++------- .../presintation/ui/onboarding/OnboardingScreen.kt | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/presintation/src/commonMain/composeResources/values/strings.xml b/presintation/src/commonMain/composeResources/values/strings.xml index 0d109f58..666d3e92 100644 --- a/presintation/src/commonMain/composeResources/values/strings.xml +++ b/presintation/src/commonMain/composeResources/values/strings.xml @@ -173,7 +173,7 @@ Recent Searches Find your next favourite - Search for products, brands or categories\nand discover amazing deals. + Search for products, brands or categoriesn and discover amazing deals. What are you looking for? Pick up where you left off No products @@ -243,7 +243,7 @@ Saving… Back You're all set! - We'll use your preferences to recommend products you\'ll love. + We'll use your preferences to recommend products you'll love. Continue Shopping Question %1$d of %2$d Qty: %1$d @@ -410,18 +410,18 @@ Start Survey Don't show again You're all set! - We'll use your preferences to recommend products you\'ll love. + We'll use your preferences to recommend products you'll love. Continue Shopping - Discover\nCurated Styles + Discover nCurated Styles Explore thousands of trendy fashion pieces handpicked just for you. - Find What\nFits You + Find What nFits You Find looks that match your style, mood, and everyday moments. - Shop. Love.\nRepeat. + Shop. Love. nRepeat. Shop your favorites, save what you love, and stay ahead of trends. Skip - Let\'s get started + Let 's get started Next diff --git a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/onboarding/OnboardingScreen.kt b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/onboarding/OnboardingScreen.kt index 0e9f4521..0ae8a04d 100644 --- a/presintation/src/commonMain/kotlin/com/troves/presintation/ui/onboarding/OnboardingScreen.kt +++ b/presintation/src/commonMain/kotlin/com/troves/presintation/ui/onboarding/OnboardingScreen.kt @@ -149,7 +149,7 @@ fun OnboardingScreen( Text( text = stringResource(ResP.string.onboarding_skip), style = Theme.typography.body.medium, - color = Theme.colors.secondaryFont + color = Theme.colors.primaryFont ) } } From 2e3571c39031f89cb767ff92087f43edac23165d Mon Sep 17 00:00:00 2001 From: yasse Date: Thu, 9 Jul 2026 12:23:24 +0300 Subject: [PATCH 15/15] add: .gitattributes for iosApp to enforce LF line endings --- iosApp/.gitattributes | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 iosApp/.gitattributes diff --git a/iosApp/.gitattributes b/iosApp/.gitattributes new file mode 100644 index 00000000..fabb5445 --- /dev/null +++ b/iosApp/.gitattributes @@ -0,0 +1,4 @@ +# Vendored binary SDK: never apply text/EOL/encoding normalization to any file inside it. +# The compiled storyboards (*.storyboardc/Info.plist) are binary property lists; a single +# re-encoded byte makes UIStoryboard reject the whole storyboard at runtime. +PaymobSDK*/** binary