281 lines
12 KiB
Kotlin
281 lines
12 KiB
Kotlin
package app.closer.ui.questions
|
|
|
|
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
|
|
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
|
import app.closer.domain.model.LocalAnswer
|
|
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 com.google.firebase.firestore.FirebaseFirestore
|
|
import com.google.firebase.firestore.ListenerRegistration
|
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
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
|
|
|
|
data class LocalQuestionUiState(
|
|
val isLoading: Boolean = true,
|
|
val error: String? = null,
|
|
val question: Question? = null,
|
|
val coupleId: String? = null,
|
|
val dailyQuestionDate: String? = null,
|
|
val submitted: Boolean = false,
|
|
val isRevealed: Boolean = false,
|
|
val pendingWrittenText: String = "",
|
|
val pendingSelectedOptionIds: List<String> = emptyList(),
|
|
val pendingScaleValue: Int = 3,
|
|
val partnerHasAnswered: Boolean = false,
|
|
val dailyMode: DailyModeResolver.DailyMode? = null
|
|
)
|
|
|
|
@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 retentionAnalytics: RetentionAnalytics,
|
|
private val db: FirebaseFirestore
|
|
) : ViewModel() {
|
|
|
|
private val _uiState = MutableStateFlow(LocalQuestionUiState())
|
|
val uiState: StateFlow<LocalQuestionUiState> = _uiState.asStateFlow()
|
|
|
|
private var partnerAnswerListener: ListenerRegistration? = null
|
|
|
|
override fun onCleared() {
|
|
super.onCleared()
|
|
partnerAnswerListener?.remove()
|
|
partnerAnswerListener = null
|
|
}
|
|
|
|
init {
|
|
loadDailyQuestion()
|
|
}
|
|
|
|
fun loadDailyQuestion() {
|
|
viewModelScope.launch {
|
|
_uiState.value = LocalQuestionUiState(isLoading = true)
|
|
try {
|
|
val resolvedMode = DailyModeResolver.resolve()
|
|
val today = FirestoreAnswerDataSource.todayLocalDateString()
|
|
val (coupleId, question) = loadCoupleAndQuestion(today, resolvedMode)
|
|
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
|
|
val partnerHasAnswered = coupleId?.let {
|
|
runCatching { checkPartnerAnswered(it, today) }.getOrDefault(false)
|
|
} ?: false
|
|
_uiState.value = LocalQuestionUiState(
|
|
isLoading = false,
|
|
question = question,
|
|
coupleId = coupleId,
|
|
dailyQuestionDate = today,
|
|
pendingScaleValue = defaultScaleValue(question),
|
|
partnerHasAnswered = partnerHasAnswered,
|
|
dailyMode = resolvedMode
|
|
).withLocalAnswer(answer)
|
|
|
|
if (coupleId != null) startPartnerAnswerObserver(coupleId, today)
|
|
question?.let { observeLocalAnswerRevealed(it.id) }
|
|
|
|
retentionAnalytics.track(RetentionEvent.DailyQuestionViewed(categoryId = question?.category))
|
|
retentionAnalytics.track(RetentionEvent.DailyModeResolved(categoryId = resolvedMode.id))
|
|
} catch (e: Exception) {
|
|
crashReporter.recordException(e)
|
|
_uiState.value = LocalQuestionUiState(
|
|
isLoading = false,
|
|
error = e.message ?: "Couldn't open today's question."
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun startPartnerAnswerObserver(coupleId: String, date: String) {
|
|
viewModelScope.launch {
|
|
val userId = authRepository.currentUserId ?: return@launch
|
|
val couple = runCatching { coupleRepository.getCoupleForUser(userId) }.getOrNull() ?: return@launch
|
|
val partnerId = couple.userIds.firstOrNull { it != userId } ?: return@launch
|
|
partnerAnswerListener?.remove()
|
|
partnerAnswerListener = db.collection("couples")
|
|
.document(coupleId)
|
|
.collection("daily_question")
|
|
.document(date)
|
|
.collection("answers")
|
|
.document(partnerId)
|
|
.addSnapshotListener { snapshot, error ->
|
|
if (error != null) return@addSnapshotListener
|
|
_uiState.update { it.copy(partnerHasAnswered = snapshot?.exists() == true) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun observeLocalAnswerRevealed(questionId: String) {
|
|
viewModelScope.launch {
|
|
localAnswerRepository.observeAnswer(questionId).collect { answer ->
|
|
_uiState.update { it.copy(isRevealed = answer?.isRevealed == true) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
_uiState.update { it.copy(pendingWrittenText = text, submitted = false) }
|
|
}
|
|
|
|
fun toggleOption(optionId: String) {
|
|
_uiState.update { state ->
|
|
val question = state.question ?: return@update state
|
|
val updated = if (question.type == "multi_choice") {
|
|
val maxSel = (question.answerConfig as? ChoiceAnswerConfigImpl)?.config?.maxSelections ?: 0
|
|
when {
|
|
optionId in state.pendingSelectedOptionIds -> state.pendingSelectedOptionIds - optionId
|
|
maxSel == 0 || state.pendingSelectedOptionIds.size < maxSel -> state.pendingSelectedOptionIds + optionId
|
|
else -> state.pendingSelectedOptionIds
|
|
}
|
|
} else {
|
|
listOf(optionId)
|
|
}
|
|
state.copy(pendingSelectedOptionIds = updated, submitted = false)
|
|
}
|
|
}
|
|
|
|
fun updateScale(value: Int) {
|
|
_uiState.update { it.copy(pendingScaleValue = value, submitted = false) }
|
|
}
|
|
|
|
fun submitAnswer() {
|
|
val state = _uiState.value
|
|
val question = state.question ?: return
|
|
if (!canSubmit(state)) return
|
|
viewModelScope.launch {
|
|
val localAnswer = state.toLocalAnswer(question).copy(updatedAt = System.currentTimeMillis())
|
|
localAnswerRepository.saveAnswer(localAnswer)
|
|
_uiState.update { it.copy(submitted = true) }
|
|
retentionAnalytics.track(RetentionEvent.DailyQuestionAnswered(categoryId = question.category))
|
|
syncAnswerToFirestore(state.coupleId, state.dailyQuestionDate, question.id, localAnswer)
|
|
}
|
|
}
|
|
|
|
fun clearSubmittedState() {
|
|
_uiState.update { it.copy(submitted = false) }
|
|
}
|
|
|
|
fun canSubmit(): Boolean = canSubmit(_uiState.value)
|
|
|
|
private fun canSubmit(state: LocalQuestionUiState): Boolean {
|
|
val question = state.question ?: return false
|
|
return when (question.type) {
|
|
"written" -> state.pendingWrittenText.isNotBlank()
|
|
"single_choice", "multi_choice", "this_or_that" -> state.pendingSelectedOptionIds.isNotEmpty()
|
|
"scale" -> true
|
|
else -> false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Syncs the local answer to Firestore so the partner can see it. Failures are
|
|
* non-blocking: the user's answer is already persisted locally.
|
|
*/
|
|
private suspend fun syncAnswerToFirestore(
|
|
coupleId: String?,
|
|
dailyQuestionDate: String?,
|
|
questionId: String,
|
|
answer: LocalAnswer
|
|
) {
|
|
val userId = authRepository.currentUserId ?: return
|
|
if (coupleId.isNullOrBlank()) return
|
|
if (dailyQuestionDate.isNullOrBlank()) return
|
|
runCatching {
|
|
firestoreAnswerDataSource.saveAnswer(coupleId, questionId, userId, answer, dailyQuestionDate)
|
|
// Mirror the sealed schema fields into local storage so the reveal screen can
|
|
// enter the sealed reveal state machine on subsequent launches.
|
|
localAnswerRepository.saveAnswer(answer.copy(schemaVersion = 3, isSealed = true, answerDate = dailyQuestionDate))
|
|
}.onFailure {
|
|
crashReporter.recordException(it)
|
|
}
|
|
// After submitting, refresh partner-answered status so the reveal button appears
|
|
// immediately if the partner answered while the user was composing.
|
|
val partnerHasAnswered = runCatching { checkPartnerAnswered(coupleId, dailyQuestionDate) }.getOrDefault(false)
|
|
_uiState.update { it.copy(partnerHasAnswered = partnerHasAnswered) }
|
|
}
|
|
|
|
private suspend fun checkPartnerAnswered(coupleId: String, date: String): Boolean {
|
|
val userId = authRepository.currentUserId ?: return false
|
|
val couple = runCatching { coupleRepository.getCoupleForUser(userId) }.getOrNull() ?: return false
|
|
val partnerId = couple.userIds.firstOrNull { it != userId } ?: return false
|
|
return firestoreAnswerDataSource.getAnswerForUser(coupleId, partnerId, date) != null
|
|
}
|
|
}
|
|
|
|
fun defaultScaleValue(question: Question?): Int {
|
|
val cfg = question?.answerConfig as? app.closer.domain.model.ScaleAnswerConfigImpl
|
|
val min = cfg?.config?.minScale ?: 1
|
|
val max = cfg?.config?.maxScale ?: 5
|
|
return ((min + max) / 2).coerceIn(min, max)
|
|
}
|