Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fce9023
feat(data): implement survey recommendations pipeline
DevOmarAmer Jul 9, 2026
969d06a
feat(data): integrate AI client for survey recommendations and update…
DevOmarAmer Jul 9, 2026
66035e0
feat(survey): add Reset intent to clear state on screen open
DevOmarAmer Jul 9, 2026
7efec4f
Merge branch 'develop' into enhancement/fixs-and-refactor
yassenRamadan1 Jul 9, 2026
2e0e825
fix: make SurveyAnswers.completedAt nullable to prevent AI endpoint r…
yassenRamadan1 Jul 9, 2026
3e62a5e
add: SurveyAnswersDto.toDomain() reverse mapper and blank completedAt…
yassenRamadan1 Jul 9, 2026
cae850a
refactor: replace device-wide isSurveyDone flag with per-account dism…
yassenRamadan1 Jul 9, 2026
319c59f
add: getSurveyAnswers() from Firestore and fix productDocumentId to s…
yassenRamadan1 Jul 9, 2026
ec32fcc
refactor: scope isSurveyDoneStream to account by reading Firestore in…
yassenRamadan1 Jul 9, 2026
8d1ec0c
refactor: fetch saved survey from Firestore inside getSurveyRecommend…
yassenRamadan1 Jul 9, 2026
7a3f91c
add: DismissSurveyBannerUseCase and register it in DomainModule
yassenRamadan1 Jul 9, 2026
5b0803e
refactor: key survey answers by SurveyKey enum so reordering steps do…
yassenRamadan1 Jul 9, 2026
1806700
add: nowIso8601() expect/actual utility for stamping survey completed…
yassenRamadan1 Jul 9, 2026
1c4268f
fix: reload Your Troves on account switch and use DismissSurveyBanner…
yassenRamadan1 Jul 9, 2026
1548e4d
fix: unescape apostrophes and newlines in strings.xml and fix onboard…
yassenRamadan1 Jul 9, 2026
2e3571c
add: .gitattributes for iosApp to enforce LF line endings
yassenRamadan1 Jul 9, 2026
86dc9f7
Merge branch 'develop' into enhancement/fixs-and-refactor
yassenRamadan1 Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ val dataModule = module {
single<AiDataSource> { AiDataSourceImpl(get()) }

// ── Remote data source ────────────────────────────────────────────────────
single<RemoteDatasource> { RemoteDatasourceImpl(get(), get()) }
single<RemoteDatasource> { RemoteDatasourceImpl(get(), get(), get<io.ktor.client.HttpClient>(named(AI_CLIENT))) }
single { CurrencyRemoteDataSource(get(named(LOCATION_CLIENT))) }

// ── Local ─────────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -139,27 +141,56 @@ class AuthenticationRepositoryFirebaseImpl(
preferences.setOnboardingDone(true)
}

override val isSurveyDoneStream: Flow<Boolean> = 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<Boolean> =
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<Unit> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -141,6 +142,35 @@ class TrovesRepositoryImpl(
}
}

override suspend fun getSurveyRecommendations(): Result<List<com.troves.domain.entity.SurveyRecommendedItem>> {
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(
cartId = resolveCartId(),
survey = surveyAnswers.toDto(),
)
remoteDataSource.getSurveyRecommendations(request).map { response ->
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 = id,
title = dto.title,
vendor = "", // API doesn't provide vendor currently
imageUrl = dto.featuredImage,
price = dto.price?.amount ?: "",
status = if (dto.available) "active" else "archived",
)
}
}
}
}

// ── Settings ────────────────────────────────────────────────────────────

override suspend fun getAds(): Result<List<Ad>> = Result.Success(localAdsDataSource.getAds())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import kotlinx.coroutines.flow.Flow

interface TrovesPreferences {
val isOnboardingDone: Flow<Boolean>
val isSurveyDone: Flow<Boolean>
/** User ids that chose "never show the survey banner again" on this device. */
val surveyBannerDismissedUids: Flow<Set<String>>
val isCartHintShown: Flow<Boolean>
val isLoggedIn: Flow<Boolean>
val selectedLanguage: Flow<String>
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ class TrovesPreferencesImpl(
.catchIOException()
.map { it[AppPreferencesKeys.IS_ONBOARDING_DONE] ?: false }

override val isSurveyDone: Flow<Boolean>
override val surveyBannerDismissedUids: Flow<Set<String>>
get() = dataStore.data
.catchIOException()
.map { it[AppPreferencesKeys.IS_SURVEY_DONE] ?: false }
.map { it[AppPreferencesKeys.SURVEY_BANNER_DISMISSED_UIDS] ?: emptySet() }

override val isCartHintShown: Flow<Boolean>
get() = dataStore.data
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit>
suspend fun getSurveyAnswers(userId: String): com.troves.data.source.remote.dto.SurveyAnswersDto?
//endregion

//region aiChats
Expand All @@ -71,4 +72,10 @@ interface RemoteDatasource {
//endregion

suspend fun getDiscountCodes(): Result<List<com.troves.domain.entity.DiscountCode>>

//region survey recommendations
suspend fun getSurveyRecommendations(
request: com.troves.data.source.remote.dto.SurveyRecommendationRequestDto,
): Result<com.troves.data.source.remote.dto.SurveyRecommendationResponseDto>
//endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProductDto> {
TODO("Not yet implemented")
Expand Down Expand Up @@ -174,6 +182,38 @@ 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<com.troves.data.source.remote.dto.SurveyAnswersDto?>("survey")
} else {
null
}
} catch (e: Exception) {
null
}
}

// ── Survey Recommendations ────────────────────────────────────────────────
override suspend fun getSurveyRecommendations(
request: com.troves.data.source.remote.dto.SurveyRecommendationRequestDto,
): Result<com.troves.data.source.remote.dto.SurveyRecommendationResponseDto> {
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) =
firestore.collection("users").document(userId).collection("aiChats")

Expand Down Expand Up @@ -208,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<List<com.troves.data.source.remote.dto.ReviewDto>> {
return try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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 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<String> = emptyList(),
val products: List<SurveyRecommendedProductDto> = emptyList(),
val cartId: String? = null
)

@Serializable
data class SurveyRecommendedProductDto(
val id: String = "",
val handle: String = "",
val title: String = "",
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 = ""
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@
<string name="home_top_brands">أفضل العلامات التجارية</string>
<string name="home_trending_now">الأكثر رواجاً</string>
<string name="home_copy_code_button">نسخ الكود</string>
<string name="home_your_troves">تروفز الخاصة بك</string>
<string name="home_your_troves_subtitle">اختيارات الذكاء الاصطناعي لك ✦</string>

<!-- See All screen headers -->
<string name="seeall_categories_title">الفئات</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@
<string name="home_top_brands">Top Brands</string>
<string name="home_trending_now">Trending Now</string>
<string name="home_copy_code_button">Copy code</string>
<string name="home_your_troves">Your Troves</string>
<string name="home_your_troves_subtitle">Picked by AI just for you ✦</string>

<!-- See All screen headers -->
<string name="seeall_categories_title">Categories</string>
Expand Down
Loading