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 89213445b9
commit 7dc14af627
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
}
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) {
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.EncryptionStatus
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.Question
import app.closer.domain.model.QuestionCategory
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.DatePlanRepository
import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.repository.UserRepository
import com.google.firebase.firestore.FirebaseFirestore
import dagger.hilt.android.lifecycle.HiltViewModel
import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput
import app.closer.ui.home.HomePriorityEngine.Priority
import java.time.DayOfWeek
import java.time.LocalDate
import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -107,7 +116,13 @@ data class HomeUiState(
val hasPartnerAnsweredToday: Boolean = false,
val partnerAnsweredQuestionId: String? = null,
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
@ -118,7 +133,11 @@ class HomeViewModel @Inject constructor(
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository,
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() {
private val _uiState = MutableStateFlow(HomeUiState())
@ -169,6 +188,49 @@ class HomeViewModel @Inject constructor(
couple.encryptionMigrationUsers[uid] != true
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 ->
current.copy(
isLoading = false,
@ -177,10 +239,14 @@ class HomeViewModel @Inject constructor(
partnerName = partnerName,
streakCount = couple?.streakCount ?: 0,
isPaired = couple != null,
coupleId = couple?.id,
coupleId = coupleId,
partnerLeftEvent = false,
needsRecovery = needsRecovery,
needsEncryptionUpgrade = needsEncryptionUpgrade
needsEncryptionUpgrade = needsEncryptionUpgrade,
hasWaitingGame = hasWaitingGame,
hasActiveChallenge = hasActiveChallenge,
hasUpcomingDatePlan = hasUpcomingDatePlan,
hasUnlockedCapsule = hasUnlockedCapsule
).refreshDailyQuestionState().withHomeActions()
}
observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id)
@ -250,6 +316,10 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers ->
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 {
it.copy(
answerStats = HomeAnswerStats(
@ -258,7 +328,8 @@ class HomeViewModel @Inject constructor(
private = answers.count { answer -> !answer.isRevealed },
latest = sorted.firstOrNull(),
answeredQuestionIds = answers.map { answer -> answer.questionId }.toSet()
)
),
weeklyRecapReady = weeklyRecapReady
).refreshDailyQuestionState().withHomeActions()
}
}
@ -334,7 +405,7 @@ class HomeViewModel @Inject constructor(
gameWaiting = hasWaitingGame(),
challengeWaiting = hasIncompleteChallenge(),
dailyQuestionUnanswered = dailyQuestionState == DailyQuestionState.UNANSWERED && dailyQuestion != null,
weeklyRecapReady = false, // TODO(Batch 5): wire weekly recap flag
weeklyRecapReady = weeklyRecapReady,
capsuleUnlocked = hasUnlockedCapsule(),
dateReminder = hasUpcomingDate(),
suggestedPackAvailable = categories.isNotEmpty(),
@ -556,25 +627,13 @@ class HomeViewModel @Inject constructor(
return actions.sortedBy { it.priority }
}
private fun HomeUiState.hasWaitingGame(): Boolean {
// TODO(Batch 3+): Replace with real QuestionSessionRepository check.
return false
}
private fun HomeUiState.hasWaitingGame(): Boolean = hasWaitingGame
private fun HomeUiState.hasIncompleteChallenge(): Boolean {
// TODO(Batch 3+): Replace with real ChallengeProgressState check.
return false
}
private fun HomeUiState.hasIncompleteChallenge(): Boolean = hasActiveChallenge
private fun HomeUiState.hasUpcomingDate(): Boolean {
// TODO(Batch 3+): Replace with real DatePlanRepository check.
return false
}
private fun HomeUiState.hasUpcomingDate(): Boolean = hasUpcomingDatePlan
private fun HomeUiState.hasUnlockedCapsule(): Boolean {
// TODO(Batch 3+): Replace with real TimeCapsule repository check.
return false
}
private fun HomeUiState.hasUnlockedCapsule(): Boolean = hasUnlockedCapsule
private fun String.toHomeLabel(): String =
split("_", "-")