feat: wire retention signals into HomeViewModel (batch v1.0.11)

- Replace TODO placeholders with real data source calls
- Add hasWaitingGame, hasActiveChallenge, hasUpcomingDatePlan, hasUnlockedCapsule, weeklyRecapReady
- Parallel async fetches in loadHome(), failures default to false
- FirestoreCapsuleDataSource.getCapsules() for capsule status checks
This commit is contained in:
null 2026-06-19 23:42:54 -05:00
parent 0e8952cab3
commit ecf24be837
3 changed files with 103 additions and 22 deletions

View File

@ -57,6 +57,28 @@ class FirestoreCapsuleDataSource @Inject constructor(private val db: FirebaseFir
return ref.id return ref.id
} }
suspend fun getCapsules(coupleId: String): List<TimeCapsule> =
col(coupleId)
.orderBy("createdAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.get()
.await()
.documents
.mapNotNull { doc ->
runCatching {
TimeCapsule(
id = doc.id,
coupleId = coupleId,
authorId = doc.getString("authorId") ?: "",
title = doc.getString("title") ?: "",
content = doc.getString("content") ?: "",
promptUsed = doc.getString("promptUsed"),
unlockAt = doc.getLong("unlockAt") ?: 0L,
createdAt = doc.getLong("createdAt") ?: 0L,
status = doc.getString("status") ?: "sealed"
)
}.getOrNull()
}
suspend fun unlockCapsule(coupleId: String, capsuleId: String) { suspend fun unlockCapsule(coupleId: String, capsuleId: String) {
col(coupleId).document(capsuleId).update("status", "unlocked").await() col(coupleId).document(capsuleId).update("status", "unlocked").await()
} }

View File

@ -6,19 +6,28 @@ import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.EncryptionStatus import app.closer.crypto.EncryptionStatus
import app.closer.data.remote.FirestoreAnswerDataSource import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.data.remote.FirestoreCapsuleDataSource
import app.closer.data.remote.FirestoreChallengeDataSource
import app.closer.domain.model.DatePlanStatus
import app.closer.domain.model.LocalAnswer import app.closer.domain.model.LocalAnswer
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.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.DatePlanRepository
import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.repository.UserRepository import app.closer.domain.repository.UserRepository
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput
import app.closer.ui.home.HomePriorityEngine.Priority import app.closer.ui.home.HomePriorityEngine.Priority
import java.time.DayOfWeek
import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
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
@ -107,7 +116,13 @@ data class HomeUiState(
val hasPartnerAnsweredToday: Boolean = false, val hasPartnerAnsweredToday: Boolean = false,
val partnerAnsweredQuestionId: String? = null, val partnerAnsweredQuestionId: String? = null,
val hasRevealedToday: Boolean = false, val hasRevealedToday: Boolean = false,
val pendingActions: List<PendingActionCard> = emptyList() val pendingActions: List<PendingActionCard> = emptyList(),
// Retention signals — populated in loadHome() and observeAnswers()
val hasWaitingGame: Boolean = false,
val hasActiveChallenge: Boolean = false,
val hasUpcomingDatePlan: Boolean = false,
val hasUnlockedCapsule: Boolean = false,
val weeklyRecapReady: Boolean = false
) )
@HiltViewModel @HiltViewModel
@ -118,7 +133,11 @@ class HomeViewModel @Inject constructor(
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val encryptionManager: CoupleEncryptionManager, private val encryptionManager: CoupleEncryptionManager,
private val db: FirebaseFirestore private val db: FirebaseFirestore,
private val questionSessionRepository: QuestionSessionRepository,
private val challengeDataSource: FirestoreChallengeDataSource,
private val capsuleDataSource: FirestoreCapsuleDataSource,
private val datePlanRepository: DatePlanRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
@ -169,6 +188,49 @@ class HomeViewModel @Inject constructor(
couple.encryptionMigrationUsers[uid] != true couple.encryptionMigrationUsers[uid] != true
else -> false else -> false
} }
// Retention signal fetches — run in parallel, failures silently default to false.
var hasWaitingGame = false
var hasActiveChallenge = false
var hasUpcomingDatePlan = false
var hasUnlockedCapsule = false
val coupleId = couple?.id
if (couple != null && coupleId != null && uid != null) {
coroutineScope {
val gameJob = async {
runCatching {
val session = questionSessionRepository.getActiveSessionForCouple(coupleId)
session != null && uid !in session.completedByUsers
}.getOrDefault(false)
}
val challengeJob = async {
runCatching {
challengeDataSource.getActiveChallengeId(coupleId) != null
}.getOrDefault(false)
}
val dateJob = async {
runCatching {
val now = System.currentTimeMillis()
val sevenDaysMs = 7L * 24 * 60 * 60 * 1000
datePlanRepository.getPlansByStatus(
coupleId, DatePlanStatus.PLANNED.toFirestoreValue()
).any { it.scheduledDate > now && it.scheduledDate - now <= sevenDaysMs }
}.getOrDefault(false)
}
val capsuleJob = async {
runCatching {
val now = System.currentTimeMillis()
capsuleDataSource.getCapsules(coupleId)
.any { it.status == "sealed" && it.unlockAt in 1L..now }
}.getOrDefault(false)
}
hasWaitingGame = gameJob.await()
hasActiveChallenge = challengeJob.await()
hasUpcomingDatePlan = dateJob.await()
hasUnlockedCapsule = capsuleJob.await()
}
}
_uiState.update { current -> _uiState.update { current ->
current.copy( current.copy(
isLoading = false, isLoading = false,
@ -177,10 +239,14 @@ class HomeViewModel @Inject constructor(
partnerName = partnerName, partnerName = partnerName,
streakCount = couple?.streakCount ?: 0, streakCount = couple?.streakCount ?: 0,
isPaired = couple != null, isPaired = couple != null,
coupleId = couple?.id, coupleId = coupleId,
partnerLeftEvent = false, partnerLeftEvent = false,
needsRecovery = needsRecovery, needsRecovery = needsRecovery,
needsEncryptionUpgrade = needsEncryptionUpgrade needsEncryptionUpgrade = needsEncryptionUpgrade,
hasWaitingGame = hasWaitingGame,
hasActiveChallenge = hasActiveChallenge,
hasUpcomingDatePlan = hasUpcomingDatePlan,
hasUnlockedCapsule = hasUnlockedCapsule
).refreshDailyQuestionState().withHomeActions() ).refreshDailyQuestionState().withHomeActions()
} }
observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id) observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id)
@ -250,6 +316,10 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers -> localAnswerRepository.observeAnswers().collect { answers ->
val sorted = answers.sortedByDescending { it.updatedAt } val sorted = answers.sortedByDescending { it.updatedAt }
val now = System.currentTimeMillis()
val sevenDaysMs = 7L * 24 * 60 * 60 * 1000
val isMonday = LocalDate.now().dayOfWeek == DayOfWeek.MONDAY
val weeklyRecapReady = isMonday && answers.any { now - it.updatedAt <= sevenDaysMs }
_uiState.update { _uiState.update {
it.copy( it.copy(
answerStats = HomeAnswerStats( answerStats = HomeAnswerStats(
@ -258,7 +328,8 @@ class HomeViewModel @Inject constructor(
private = answers.count { answer -> !answer.isRevealed }, private = answers.count { answer -> !answer.isRevealed },
latest = sorted.firstOrNull(), latest = sorted.firstOrNull(),
answeredQuestionIds = answers.map { answer -> answer.questionId }.toSet() answeredQuestionIds = answers.map { answer -> answer.questionId }.toSet()
) ),
weeklyRecapReady = weeklyRecapReady
).refreshDailyQuestionState().withHomeActions() ).refreshDailyQuestionState().withHomeActions()
} }
} }
@ -334,7 +405,7 @@ class HomeViewModel @Inject constructor(
gameWaiting = hasWaitingGame(), gameWaiting = hasWaitingGame(),
challengeWaiting = hasIncompleteChallenge(), challengeWaiting = hasIncompleteChallenge(),
dailyQuestionUnanswered = dailyQuestionState == DailyQuestionState.UNANSWERED && dailyQuestion != null, dailyQuestionUnanswered = dailyQuestionState == DailyQuestionState.UNANSWERED && dailyQuestion != null,
weeklyRecapReady = false, // TODO(Batch 5): wire weekly recap flag weeklyRecapReady = weeklyRecapReady,
capsuleUnlocked = hasUnlockedCapsule(), capsuleUnlocked = hasUnlockedCapsule(),
dateReminder = hasUpcomingDate(), dateReminder = hasUpcomingDate(),
suggestedPackAvailable = categories.isNotEmpty(), suggestedPackAvailable = categories.isNotEmpty(),
@ -556,25 +627,13 @@ class HomeViewModel @Inject constructor(
return actions.sortedBy { it.priority } return actions.sortedBy { it.priority }
} }
private fun HomeUiState.hasWaitingGame(): Boolean { private fun HomeUiState.hasWaitingGame(): Boolean = hasWaitingGame
// TODO(Batch 3+): Replace with real QuestionSessionRepository check.
return false
}
private fun HomeUiState.hasIncompleteChallenge(): Boolean { private fun HomeUiState.hasIncompleteChallenge(): Boolean = hasActiveChallenge
// TODO(Batch 3+): Replace with real ChallengeProgressState check.
return false
}
private fun HomeUiState.hasUpcomingDate(): Boolean { private fun HomeUiState.hasUpcomingDate(): Boolean = hasUpcomingDatePlan
// TODO(Batch 3+): Replace with real DatePlanRepository check.
return false
}
private fun HomeUiState.hasUnlockedCapsule(): Boolean { private fun HomeUiState.hasUnlockedCapsule(): Boolean = hasUnlockedCapsule
// TODO(Batch 3+): Replace with real TimeCapsule repository check.
return false
}
private fun String.toHomeLabel(): String = private fun String.toHomeLabel(): String =
split("_", "-") split("_", "-")