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:
parent
89213445b9
commit
7dc14af627
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("_", "-")
|
||||
|
|
|
|||
Loading…
Reference in New Issue