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
0e8952cab3
commit
ecf24be837
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("_", "-")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue