diff --git a/app/src/main/java/app/closer/data/local/QuestionDao.kt b/app/src/main/java/app/closer/data/local/QuestionDao.kt index f750846b..76bc36fb 100644 --- a/app/src/main/java/app/closer/data/local/QuestionDao.kt +++ b/app/src/main/java/app/closer/data/local/QuestionDao.kt @@ -12,20 +12,42 @@ interface QuestionDao { @Query("SELECT * FROM question WHERE id = :id AND status = 'active' AND TRIM(text) <> '' LIMIT 1") suspend fun getQuestionById(id: String): QuestionEntity? - @Query("SELECT * FROM question WHERE status = 'active' AND is_premium = 0 AND TRIM(text) <> '' AND category_id <> 'unknown' ORDER BY RANDOM() LIMIT 1") - suspend fun getDailyQuestion(): QuestionEntity? + // Daily-question pools are selected DETERMINISTICALLY per day: a stable `ORDER BY id` + // plus a date-derived OFFSET (computed in the repository). RANDOM() previously returned a + // different question on every call, which made the daily question change between reloads + // (breaking "already answered" detection → re-asking) and differ between the two partners. + // Ordering by id + a shared day offset yields the same question across reloads and across + // both partners' devices (they share this question DB). - @Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1") - suspend fun getDailyQuestionByModeTag(modeTag: String): QuestionEntity? + @Query("SELECT COUNT(*) FROM question WHERE status = 'active' AND is_premium = 0 AND TRIM(text) <> '' AND category_id <> 'unknown'") + suspend fun countDailyQuestion(): Int - @Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1") - suspend fun getDailyQuestionFromPack(): QuestionEntity? + @Query("SELECT * FROM question WHERE status = 'active' AND is_premium = 0 AND TRIM(text) <> '' AND category_id <> 'unknown' ORDER BY id LIMIT 1 OFFSET :offset") + suspend fun getDailyQuestionAt(offset: Int): QuestionEntity? - @Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1") - suspend fun getFreeDailyQuestionByModeTag(modeTag: String): QuestionEntity? + @Query("SELECT COUNT(*) FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> ''") + suspend fun countDailyQuestionByModeTag(modeTag: String): Int - @Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1") - suspend fun getFreeDailyQuestionFromPack(): QuestionEntity? + @Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY id LIMIT 1 OFFSET :offset") + suspend fun getDailyQuestionByModeTagAt(modeTag: String, offset: Int): QuestionEntity? + + @Query("SELECT COUNT(*) FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%quick_answer%' AND TRIM(text) <> ''") + suspend fun countDailyQuestionFromPack(): Int + + @Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY id LIMIT 1 OFFSET :offset") + suspend fun getDailyQuestionFromPackAt(offset: Int): QuestionEntity? + + @Query("SELECT COUNT(*) FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> ''") + suspend fun countFreeDailyQuestionByModeTag(modeTag: String): Int + + @Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY id LIMIT 1 OFFSET :offset") + suspend fun getFreeDailyQuestionByModeTagAt(modeTag: String, offset: Int): QuestionEntity? + + @Query("SELECT COUNT(*) FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%quick_answer%' AND TRIM(text) <> ''") + suspend fun countFreeDailyQuestionFromPack(): Int + + @Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY id LIMIT 1 OFFSET :offset") + suspend fun getFreeDailyQuestionFromPackAt(offset: Int): QuestionEntity? @Query("SELECT * FROM question WHERE category_id = :categoryId AND status = 'active' AND TRIM(text) <> '' ORDER BY depth_level ASC, id ASC") suspend fun getQuestionsByCategory(categoryId: String): List diff --git a/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt b/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt index 53e097d2..d82144e3 100644 --- a/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt @@ -24,6 +24,9 @@ class FirebaseAuthDataSource @Inject constructor() { val currentUserId: String? get() = auth.currentUser?.uid val currentUserEmail: String? get() = auth.currentUser?.email + /** Provider profile photo (e.g. the Google avatar) — fallback when the user doc has none yet. */ + val currentUserPhotoUrl: String? get() = auth.currentUser?.photoUrl?.toString() + val currentUserDisplayName: String? get() = auth.currentUser?.displayName val isSignedIn: Boolean get() = auth.currentUser != null val isAnonymous: Boolean get() = auth.currentUser?.isAnonymous ?: false val isEmailVerified: Boolean get() = auth.currentUser?.isEmailVerified ?: false diff --git a/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt index ab6a0221..cc3096ef 100644 --- a/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt @@ -18,6 +18,8 @@ class FirebaseAuthRepositoryImpl @Inject constructor( override val authState: Flow = dataSource.authState override val currentUserId: String? get() = dataSource.currentUserId override val currentUserEmail: String? get() = dataSource.currentUserEmail + override val currentUserPhotoUrl: String? get() = dataSource.currentUserPhotoUrl + override val currentUserDisplayName: String? get() = dataSource.currentUserDisplayName override val isSignedIn: Boolean get() = dataSource.isSignedIn override val isAnonymous: Boolean get() = dataSource.isAnonymous override val isGoogleAccount: Boolean get() = dataSource.isGoogleAccount diff --git a/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt b/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt index f276f819..94a92e4c 100644 --- a/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt +++ b/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt @@ -7,6 +7,7 @@ import app.closer.data.local.mapper.toQuestionCategory import app.closer.domain.model.Question import app.closer.domain.model.QuestionCategory import app.closer.domain.repository.QuestionRepository +import java.time.LocalDate import javax.inject.Inject import javax.inject.Singleton @@ -15,22 +16,53 @@ class RoomQuestionRepository @Inject constructor( private val questionDao: QuestionDao, private val categoryDao: CategoryDao ) : QuestionRepository { + + /** + * Deterministic per-day index into a pool of [count] questions. Seeded by the local + * calendar day so the chosen daily question is stable across reloads and identical on + * both partners' devices (which share this question DB) — never RANDOM(). + */ + private fun dailyOffset(count: Int): Int = + if (count <= 0) 0 else (LocalDate.now().toEpochDay() % count).toInt() + override suspend fun getDailyQuestion(): Question? { - return questionDao.getDailyQuestion()?.toQuestion() + val count = questionDao.countDailyQuestion() + if (count == 0) return null + return questionDao.getDailyQuestionAt(dailyOffset(count))?.toQuestion() } override suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question? { return if (isPremium) { - questionDao.getDailyQuestionByModeTag(modeTag)?.toQuestion() - ?: questionDao.getDailyQuestionFromPack()?.toQuestion() - ?: questionDao.getDailyQuestion()?.toQuestion() + dailyByModeTag(modeTag) ?: dailyFromPack() ?: getDailyQuestion() } else { - questionDao.getFreeDailyQuestionByModeTag(modeTag)?.toQuestion() - ?: questionDao.getFreeDailyQuestionFromPack()?.toQuestion() - ?: questionDao.getDailyQuestion()?.toQuestion() + freeDailyByModeTag(modeTag) ?: freeDailyFromPack() ?: getDailyQuestion() } } + private suspend fun dailyByModeTag(modeTag: String): Question? { + val count = questionDao.countDailyQuestionByModeTag(modeTag) + if (count == 0) return null + return questionDao.getDailyQuestionByModeTagAt(modeTag, dailyOffset(count))?.toQuestion() + } + + private suspend fun dailyFromPack(): Question? { + val count = questionDao.countDailyQuestionFromPack() + if (count == 0) return null + return questionDao.getDailyQuestionFromPackAt(dailyOffset(count))?.toQuestion() + } + + private suspend fun freeDailyByModeTag(modeTag: String): Question? { + val count = questionDao.countFreeDailyQuestionByModeTag(modeTag) + if (count == 0) return null + return questionDao.getFreeDailyQuestionByModeTagAt(modeTag, dailyOffset(count))?.toQuestion() + } + + private suspend fun freeDailyFromPack(): Question? { + val count = questionDao.countFreeDailyQuestionFromPack() + if (count == 0) return null + return questionDao.getFreeDailyQuestionFromPackAt(dailyOffset(count))?.toQuestion() + } + override suspend fun getQuestionById(id: String): Question? { return questionDao.getQuestionById(id)?.toQuestion() } diff --git a/app/src/main/java/app/closer/domain/repository/AuthRepository.kt b/app/src/main/java/app/closer/domain/repository/AuthRepository.kt index 74f1d975..ae3f7cf6 100644 --- a/app/src/main/java/app/closer/domain/repository/AuthRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/AuthRepository.kt @@ -8,6 +8,8 @@ interface AuthRepository { val authState: Flow val currentUserId: String? val currentUserEmail: String? + val currentUserPhotoUrl: String? + val currentUserDisplayName: String? val isSignedIn: Boolean val isAnonymous: Boolean val isGoogleAccount: Boolean diff --git a/app/src/main/java/app/closer/domain/usecase/DailyQuestionResolver.kt b/app/src/main/java/app/closer/domain/usecase/DailyQuestionResolver.kt new file mode 100644 index 00000000..e8d85d04 --- /dev/null +++ b/app/src/main/java/app/closer/domain/usecase/DailyQuestionResolver.kt @@ -0,0 +1,83 @@ +package app.closer.domain.usecase + +import app.closer.core.billing.EntitlementChecker +import app.closer.core.crash.CrashReporter +import app.closer.data.remote.FirestoreAnswerDataSource +import app.closer.domain.DailyModeResolver +import app.closer.domain.model.Question +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.CoupleRepository +import app.closer.domain.repository.QuestionRepository +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Single source of truth for "today's daily question" so the Home card and the daily-question + * screen always resolve the SAME question. Previously Home used [QuestionRepository.getDailyQuestion] + * (a different pool) while the screen used the Firestore assignment + mode pool — they showed + * different questions, so Home's answered/waiting detection never matched what the user answered, + * which made it re-ask after answering. + * + * Resolution order (paired): Firestore assignment → local question by that id → deterministic + * mode-tagged daily question → deterministic generic daily question. Unpaired falls straight to + * the deterministic local question for today's mode. + */ +@Singleton +class DailyQuestionResolver @Inject constructor( + private val questionRepository: QuestionRepository, + private val coupleRepository: CoupleRepository, + private val authRepository: AuthRepository, + private val firestoreAnswerDataSource: FirestoreAnswerDataSource, + private val entitlementChecker: EntitlementChecker, + private val crashReporter: CrashReporter, +) { + data class Resolved( + val coupleId: String?, + val question: Question?, + val date: String, + val mode: DailyModeResolver.DailyMode, + ) + + suspend fun resolve(): Resolved { + val mode = DailyModeResolver.resolve() + val today = FirestoreAnswerDataSource.todayLocalDateString() + val isPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false) + + val couple = authRepository.currentUserId?.let { uid -> + runCatching { coupleRepository.getCoupleForUser(uid) } + .onFailure { crashReporter.recordException(it) } + .getOrNull() + } + + if (couple == null) { + val q = questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium) + ?: questionRepository.getDailyQuestion() + return Resolved(coupleId = null, question = q, date = today, mode = mode) + } + + val coupleId = couple.id + val assignment = runCatching { + firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today) + }.onFailure { crashReporter.recordException(it) }.getOrNull() + + val question = if (assignment != null) { + questionRepository.getQuestionById(assignment.questionId) + ?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium) + ?: questionRepository.getDailyQuestion() + } else { + // No assignment yet — request one, but stay usable with the deterministic local + // question (identical on both devices) if the call fails. + runCatching { + firestoreAnswerDataSource.requestDailyQuestionAssignment(coupleId, today) + questionRepository.getQuestionById( + firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: "" + ) + }.onFailure { crashReporter.recordException(it) }.getOrNull() + ?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium) + ?: questionRepository.getDailyQuestion() + } + + return Resolved(coupleId = coupleId, question = question, date = today, mode = mode) + } +} diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 0df68c3a..fb26e035 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -161,7 +161,8 @@ class HomeViewModel @Inject constructor( private val sealedRevealManager: SealedRevealManager, private val outcomeRepository: OutcomeRepository, private val settingsRepository: SettingsRepository, - private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource + private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource, + private val dailyQuestionResolver: app.closer.domain.usecase.DailyQuestionResolver ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) @@ -198,7 +199,9 @@ class HomeViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } try { - val dailyQuestion = questionRepository.getDailyQuestion() + // Resolve via the shared resolver so Home shows the SAME question as the + // daily-question screen (assignment-backed), keeping answered/waiting state in sync. + val dailyQuestion = dailyQuestionResolver.resolve().question val categories = questionRepository.getCategories() .take(6) .map { category -> diff --git a/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt b/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt index 632d2aba..d6c18a27 100644 --- a/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt @@ -106,10 +106,18 @@ class PairingSuccessViewModel @Inject constructor( val partnerId = couple?.userIds?.firstOrNull { it != myId } val partner = partnerId?.let { runCatching { userRepository.getUser(it) }.getOrNull() } + // Fall back to the Firebase Auth profile (e.g. the Google avatar) if the user + // doc photo/name hasn't propagated yet, so both faces show on the congrats screen. + val myPhoto = me?.photoUrl?.takeIf { it.isNotBlank() } + ?: authRepository.currentUserPhotoUrl?.takeIf { it.isNotBlank() } + ?: "" + val myName = me?.displayName?.takeIf { n -> n.isNotBlank() } + ?: authRepository.currentUserDisplayName?.takeIf { n -> n.isNotBlank() } + ?: "You" _uiState.update { it.copy( - myName = me?.displayName?.takeIf { n -> n.isNotBlank() } ?: "You", - myPhotoUrl = me?.photoUrl ?: "", + myName = myName, + myPhotoUrl = myPhoto, partnerName = partner?.displayName?.takeIf { n -> n.isNotBlank() } ?: "Your partner", partnerPhotoUrl = partner?.photoUrl ?: "" ) diff --git a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt index 33ac9d41..14f28f5f 100644 --- a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.analytics.RetentionAnalytics import app.closer.analytics.RetentionEvent -import app.closer.core.billing.EntitlementChecker import app.closer.core.crash.CrashReporter import app.closer.data.remote.FirestoreAnswerDataSource import app.closer.domain.DailyModeResolver @@ -14,7 +13,7 @@ import app.closer.domain.model.Question import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.LocalAnswerRepository -import app.closer.domain.repository.QuestionRepository +import app.closer.domain.usecase.DailyQuestionResolver import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.ListenerRegistration import dagger.hilt.android.lifecycle.HiltViewModel @@ -22,7 +21,6 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -43,13 +41,12 @@ data class LocalQuestionUiState( @HiltViewModel class DailyQuestionViewModel @Inject constructor( - private val repository: QuestionRepository, private val localAnswerRepository: LocalAnswerRepository, private val firestoreAnswerDataSource: FirestoreAnswerDataSource, private val authRepository: AuthRepository, private val coupleRepository: CoupleRepository, private val crashReporter: CrashReporter, - private val entitlementChecker: EntitlementChecker, + private val dailyQuestionResolver: DailyQuestionResolver, private val retentionAnalytics: RetentionAnalytics, private val db: FirebaseFirestore ) : ViewModel() { @@ -73,9 +70,11 @@ class DailyQuestionViewModel @Inject constructor( viewModelScope.launch { _uiState.value = LocalQuestionUiState(isLoading = true) try { - val resolvedMode = DailyModeResolver.resolve() - val today = FirestoreAnswerDataSource.todayLocalDateString() - val (coupleId, question) = loadCoupleAndQuestion(today, resolvedMode) + val resolved = dailyQuestionResolver.resolve() + val resolvedMode = resolved.mode + val today = resolved.date + val coupleId = resolved.coupleId + val question = resolved.question val answer = question?.let { localAnswerRepository.getAnswer(it.id) } val partnerHasAnswered = coupleId?.let { runCatching { checkPartnerAnswered(it, today) }.getOrDefault(false) @@ -132,57 +131,6 @@ class DailyQuestionViewModel @Inject constructor( } } - /** - * Resolves the current couple (if any) and the daily question. - * - * For paired users, read the couple's assigned daily question from Firestore - * so both partners see the same prompt. If no assignment exists yet, request - * one from the cloud function and fall back to a local mode-tagged question. - * - * For unpaired users, fall back to a mode-tagged question from the daily_fun_mc pack. - */ - private suspend fun loadCoupleAndQuestion( - today: String, - mode: DailyModeResolver.DailyMode - ): Pair { - val isPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false) - - val couple = authRepository.currentUserId?.let { uid -> - runCatching { coupleRepository.getCoupleForUser(uid) } - .onFailure { crashReporter.recordException(it) } - .getOrNull() - } - - if (couple == null) { - return null to (repository.getDailyQuestionForMode(mode.modeTag, isPremium) - ?: repository.getDailyQuestion()) - } - - val coupleId = couple.id - val assignment = runCatching { - firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today) - }.onFailure { crashReporter.recordException(it) }.getOrNull() - - val question = if (assignment != null) { - repository.getQuestionById(assignment.questionId) - ?: repository.getDailyQuestionForMode(mode.modeTag, isPremium) - ?: repository.getDailyQuestion() - } else { - // No assignment yet. Request immediate assignment, but keep the app - // usable with a local mode-tagged question in case the call fails. - runCatching { - firestoreAnswerDataSource.requestDailyQuestionAssignment(coupleId, today) - repository.getQuestionById( - firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: "" - ) - }.onFailure { crashReporter.recordException(it) }.getOrNull() - ?: repository.getDailyQuestionForMode(mode.modeTag, isPremium) - ?: repository.getDailyQuestion() - } - - return coupleId to question - } - fun updateWrittenText(text: String) { _uiState.update { it.copy(pendingWrittenText = text, submitted = false) } }