From 7dc14af627ca481a4cd414d7008e1a76e14612c0 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 19 Jun 2026 23:42:54 -0500 Subject: [PATCH] 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 --- ...otlin-compiler-14341001045275348642.salive | 0 .../data/remote/FirestoreCapsuleDataSource.kt | 22 ++++ .../java/app/closer/ui/home/HomeViewModel.kt | 103 ++++++++++++++---- 3 files changed, 103 insertions(+), 22 deletions(-) delete mode 100644 .kotlin/sessions/kotlin-compiler-14341001045275348642.salive diff --git a/.kotlin/sessions/kotlin-compiler-14341001045275348642.salive b/.kotlin/sessions/kotlin-compiler-14341001045275348642.salive deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt index e23f0b18..025208a5 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt @@ -57,6 +57,28 @@ class FirestoreCapsuleDataSource @Inject constructor(private val db: FirebaseFir return ref.id } + suspend fun getCapsules(coupleId: String): List = + 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() } diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index ac8221d5..5b081421 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -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 = emptyList() + val pendingActions: List = 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("_", "-")