Closer/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt

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)
}