fix(daily-question): deterministic per-day offset replaces RANDOM(); shared DailyQuestionResolver; auth profile fallback
This commit is contained in:
parent
6d74c6acec
commit
77208ff1e6
|
|
@ -12,20 +12,42 @@ interface QuestionDao {
|
||||||
@Query("SELECT * FROM question WHERE id = :id AND status = 'active' AND TRIM(text) <> '' LIMIT 1")
|
@Query("SELECT * FROM question WHERE id = :id AND status = 'active' AND TRIM(text) <> '' LIMIT 1")
|
||||||
suspend fun getQuestionById(id: String): QuestionEntity?
|
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")
|
// Daily-question pools are selected DETERMINISTICALLY per day: a stable `ORDER BY id`
|
||||||
suspend fun getDailyQuestion(): QuestionEntity?
|
// 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")
|
@Query("SELECT COUNT(*) FROM question WHERE status = 'active' AND is_premium = 0 AND TRIM(text) <> '' AND category_id <> 'unknown'")
|
||||||
suspend fun getDailyQuestionByModeTag(modeTag: String): QuestionEntity?
|
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")
|
@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 getDailyQuestionFromPack(): QuestionEntity?
|
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")
|
@Query("SELECT COUNT(*) FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> ''")
|
||||||
suspend fun getFreeDailyQuestionByModeTag(modeTag: String): QuestionEntity?
|
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")
|
@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 getFreeDailyQuestionFromPack(): QuestionEntity?
|
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")
|
@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<QuestionEntity>
|
suspend fun getQuestionsByCategory(categoryId: String): List<QuestionEntity>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ class FirebaseAuthDataSource @Inject constructor() {
|
||||||
|
|
||||||
val currentUserId: String? get() = auth.currentUser?.uid
|
val currentUserId: String? get() = auth.currentUser?.uid
|
||||||
val currentUserEmail: String? get() = auth.currentUser?.email
|
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 isSignedIn: Boolean get() = auth.currentUser != null
|
||||||
val isAnonymous: Boolean get() = auth.currentUser?.isAnonymous ?: false
|
val isAnonymous: Boolean get() = auth.currentUser?.isAnonymous ?: false
|
||||||
val isEmailVerified: Boolean get() = auth.currentUser?.isEmailVerified ?: false
|
val isEmailVerified: Boolean get() = auth.currentUser?.isEmailVerified ?: false
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
|
||||||
override val authState: Flow<AuthState> = dataSource.authState
|
override val authState: Flow<AuthState> = dataSource.authState
|
||||||
override val currentUserId: String? get() = dataSource.currentUserId
|
override val currentUserId: String? get() = dataSource.currentUserId
|
||||||
override val currentUserEmail: String? get() = dataSource.currentUserEmail
|
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 isSignedIn: Boolean get() = dataSource.isSignedIn
|
||||||
override val isAnonymous: Boolean get() = dataSource.isAnonymous
|
override val isAnonymous: Boolean get() = dataSource.isAnonymous
|
||||||
override val isGoogleAccount: Boolean get() = dataSource.isGoogleAccount
|
override val isGoogleAccount: Boolean get() = dataSource.isGoogleAccount
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import app.closer.data.local.mapper.toQuestionCategory
|
||||||
import app.closer.domain.model.Question
|
import app.closer.domain.model.Question
|
||||||
import app.closer.domain.model.QuestionCategory
|
import app.closer.domain.model.QuestionCategory
|
||||||
import app.closer.domain.repository.QuestionRepository
|
import app.closer.domain.repository.QuestionRepository
|
||||||
|
import java.time.LocalDate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -15,22 +16,53 @@ class RoomQuestionRepository @Inject constructor(
|
||||||
private val questionDao: QuestionDao,
|
private val questionDao: QuestionDao,
|
||||||
private val categoryDao: CategoryDao
|
private val categoryDao: CategoryDao
|
||||||
) : QuestionRepository {
|
) : 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? {
|
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? {
|
override suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question? {
|
||||||
return if (isPremium) {
|
return if (isPremium) {
|
||||||
questionDao.getDailyQuestionByModeTag(modeTag)?.toQuestion()
|
dailyByModeTag(modeTag) ?: dailyFromPack() ?: getDailyQuestion()
|
||||||
?: questionDao.getDailyQuestionFromPack()?.toQuestion()
|
|
||||||
?: questionDao.getDailyQuestion()?.toQuestion()
|
|
||||||
} else {
|
} else {
|
||||||
questionDao.getFreeDailyQuestionByModeTag(modeTag)?.toQuestion()
|
freeDailyByModeTag(modeTag) ?: freeDailyFromPack() ?: getDailyQuestion()
|
||||||
?: questionDao.getFreeDailyQuestionFromPack()?.toQuestion()
|
|
||||||
?: questionDao.getDailyQuestion()?.toQuestion()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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? {
|
override suspend fun getQuestionById(id: String): Question? {
|
||||||
return questionDao.getQuestionById(id)?.toQuestion()
|
return questionDao.getQuestionById(id)?.toQuestion()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ interface AuthRepository {
|
||||||
val authState: Flow<AuthState>
|
val authState: Flow<AuthState>
|
||||||
val currentUserId: String?
|
val currentUserId: String?
|
||||||
val currentUserEmail: String?
|
val currentUserEmail: String?
|
||||||
|
val currentUserPhotoUrl: String?
|
||||||
|
val currentUserDisplayName: String?
|
||||||
val isSignedIn: Boolean
|
val isSignedIn: Boolean
|
||||||
val isAnonymous: Boolean
|
val isAnonymous: Boolean
|
||||||
val isGoogleAccount: Boolean
|
val isGoogleAccount: Boolean
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -161,7 +161,8 @@ class HomeViewModel @Inject constructor(
|
||||||
private val sealedRevealManager: SealedRevealManager,
|
private val sealedRevealManager: SealedRevealManager,
|
||||||
private val outcomeRepository: OutcomeRepository,
|
private val outcomeRepository: OutcomeRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
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() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(HomeUiState())
|
private val _uiState = MutableStateFlow(HomeUiState())
|
||||||
|
|
@ -198,7 +199,9 @@ class HomeViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
try {
|
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()
|
val categories = questionRepository.getCategories()
|
||||||
.take(6)
|
.take(6)
|
||||||
.map { category ->
|
.map { category ->
|
||||||
|
|
|
||||||
|
|
@ -106,10 +106,18 @@ class PairingSuccessViewModel @Inject constructor(
|
||||||
val partnerId = couple?.userIds?.firstOrNull { it != myId }
|
val partnerId = couple?.userIds?.firstOrNull { it != myId }
|
||||||
val partner = partnerId?.let { runCatching { userRepository.getUser(it) }.getOrNull() }
|
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 {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
myName = me?.displayName?.takeIf { n -> n.isNotBlank() } ?: "You",
|
myName = myName,
|
||||||
myPhotoUrl = me?.photoUrl ?: "",
|
myPhotoUrl = myPhoto,
|
||||||
partnerName = partner?.displayName?.takeIf { n -> n.isNotBlank() } ?: "Your partner",
|
partnerName = partner?.displayName?.takeIf { n -> n.isNotBlank() } ?: "Your partner",
|
||||||
partnerPhotoUrl = partner?.photoUrl ?: ""
|
partnerPhotoUrl = partner?.photoUrl ?: ""
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.analytics.RetentionAnalytics
|
import app.closer.analytics.RetentionAnalytics
|
||||||
import app.closer.analytics.RetentionEvent
|
import app.closer.analytics.RetentionEvent
|
||||||
import app.closer.core.billing.EntitlementChecker
|
|
||||||
import app.closer.core.crash.CrashReporter
|
import app.closer.core.crash.CrashReporter
|
||||||
import app.closer.data.remote.FirestoreAnswerDataSource
|
import app.closer.data.remote.FirestoreAnswerDataSource
|
||||||
import app.closer.domain.DailyModeResolver
|
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.AuthRepository
|
||||||
import app.closer.domain.repository.CoupleRepository
|
import app.closer.domain.repository.CoupleRepository
|
||||||
import app.closer.domain.repository.LocalAnswerRepository
|
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.FirebaseFirestore
|
||||||
import com.google.firebase.firestore.ListenerRegistration
|
import com.google.firebase.firestore.ListenerRegistration
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
|
@ -22,7 +21,6 @@ import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -43,13 +41,12 @@ data class LocalQuestionUiState(
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DailyQuestionViewModel @Inject constructor(
|
class DailyQuestionViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
|
||||||
private val localAnswerRepository: LocalAnswerRepository,
|
private val localAnswerRepository: LocalAnswerRepository,
|
||||||
private val firestoreAnswerDataSource: FirestoreAnswerDataSource,
|
private val firestoreAnswerDataSource: FirestoreAnswerDataSource,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
private val crashReporter: CrashReporter,
|
private val crashReporter: CrashReporter,
|
||||||
private val entitlementChecker: EntitlementChecker,
|
private val dailyQuestionResolver: DailyQuestionResolver,
|
||||||
private val retentionAnalytics: RetentionAnalytics,
|
private val retentionAnalytics: RetentionAnalytics,
|
||||||
private val db: FirebaseFirestore
|
private val db: FirebaseFirestore
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
@ -73,9 +70,11 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = LocalQuestionUiState(isLoading = true)
|
_uiState.value = LocalQuestionUiState(isLoading = true)
|
||||||
try {
|
try {
|
||||||
val resolvedMode = DailyModeResolver.resolve()
|
val resolved = dailyQuestionResolver.resolve()
|
||||||
val today = FirestoreAnswerDataSource.todayLocalDateString()
|
val resolvedMode = resolved.mode
|
||||||
val (coupleId, question) = loadCoupleAndQuestion(today, resolvedMode)
|
val today = resolved.date
|
||||||
|
val coupleId = resolved.coupleId
|
||||||
|
val question = resolved.question
|
||||||
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
|
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
|
||||||
val partnerHasAnswered = coupleId?.let {
|
val partnerHasAnswered = coupleId?.let {
|
||||||
runCatching { checkPartnerAnswered(it, today) }.getOrDefault(false)
|
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<String?, Question?> {
|
|
||||||
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) {
|
fun updateWrittenText(text: String) {
|
||||||
_uiState.update { it.copy(pendingWrittenText = text, submitted = false) }
|
_uiState.update { it.copy(pendingWrittenText = text, submitted = false) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue