Closer/app/src/main/java/app/closer/ui/home/HomeViewModel.kt

916 lines
40 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package app.closer.ui.home
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.EncryptionStatus
import app.closer.crypto.SealedRevealManager
import app.closer.domain.model.GameType
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 app.closer.domain.model.Outcome
import app.closer.domain.model.OutcomeDay
import app.closer.domain.model.OutcomeScores
import app.closer.domain.repository.OutcomeRepository
import app.closer.domain.repository.SettingsRepository
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.functions.FirebaseFunctions
import com.google.firebase.functions.FirebaseFunctionsException
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 java.time.ZoneId
import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
data class HomeCategorySummary(
val category: QuestionCategory,
val questionCount: Int
)
data class HomeAnswerStats(
val total: Int = 0,
val revealed: Int = 0,
val private: Int = 0,
val latest: LocalAnswer? = null,
val answeredQuestionIds: Set<String> = emptySet()
)
enum class HomeActionTarget {
InvitePartner,
DailyQuestion,
AnswerHistory,
QuestionPacks,
Settings,
AnswerReveal,
Game,
Challenge,
DatePlan,
MemoryCapsule,
DateMemories
}
enum class HomeActionTone {
Invite,
Daily,
Reflection,
Ritual,
Starter,
Pack,
Utility,
Pending
}
data class HomeAction(
val eyebrow: String,
val title: String,
val body: String,
val cta: String,
val target: HomeActionTarget,
val tone: HomeActionTone,
val metric: String? = null,
val categoryId: String? = null,
// For the "your partner is waiting to play" CTA: the specific game route to resume
// (so "Play now" jumps into the actual waiting game, not the generic Play hub). B-002.
val gameRoute: String? = null
)
data class PendingActionCard(
val title: String,
val subtitle: String?,
val priority: Int,
val target: HomeActionTarget
)
/**
* The entry route that resumes an in-progress game of [gameType]. Each game screen
* detects the couple's active session on open and joins it, so navigating here lets the
* Home "Play now" CTA drop the user straight back into the waiting game (B-002).
*/
private fun gameRouteFor(gameType: String?): String? = when (gameType) {
GameType.WHEEL -> AppRoute.SPIN_WHEEL_RANDOM
GameType.THIS_OR_THAT -> AppRoute.THIS_OR_THAT
GameType.HOW_WELL -> AppRoute.HOW_WELL
GameType.DESIRE_SYNC -> AppRoute.DESIRE_SYNC
else -> null
}
enum class DailyQuestionState {
UNANSWERED,
USER_ANSWERED_PARTNER_PENDING,
PARTNER_ANSWERED_USER_PENDING,
BOTH_ANSWERED,
REVEALED
}
data class HomeUiState(
val isLoading: Boolean = true,
val error: String? = null,
val dailyQuestion: Question? = null,
val categories: List<HomeCategorySummary> = emptyList(),
val answerStats: HomeAnswerStats = HomeAnswerStats(),
val partnerName: String? = null,
val partnerPhotoUrl: String? = null,
val streakCount: Int = 0,
/** When the couple was created (millis), for the partner-sheet "together since" glance. 0 = unknown. */
val togetherSince: Long = 0L,
val isPaired: Boolean = false,
val primaryAction: HomeAction? = null,
val secondaryActions: List<HomeAction> = emptyList(),
val partnerLeftEvent: Boolean = false,
val needsRecovery: Boolean = false,
val coupleId: String? = null,
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
val hasPartnerAnsweredToday: Boolean = false,
val partnerAnsweredQuestionId: String? = null,
val hasRevealedToday: Boolean = false,
val pendingActions: List<PendingActionCard> = emptyList(),
// Retention signals — populated in loadHome() and observeAnswers()
val hasWaitingGame: Boolean = false,
// The route of the active game waiting for this user, so the Home "Play now" CTA
// resumes that specific game instead of dumping on the generic Play hub (B-002).
val waitingGameRoute: String? = null,
// The waiting game's type (e.g. "wheel"), so the Home card can name the game ("… in Spin the Wheel").
val waitingGameType: String? = null,
val hasActiveChallenge: Boolean = false,
val hasUpcomingDatePlan: Boolean = false,
val hasUnlockedCapsule: Boolean = false,
// A completed date this user hasn't reflected on yet (drives the Home "reflect on your date" nudge).
val hasPendingDateReflection: Boolean = false,
val weeklyRecapReady: Boolean = false,
val reminderSentEvent: Boolean = false,
/** "Thinking of you" nudge: in-flight guard + one-shot snackbar message (success or friendly error). */
val isSendingNudge: Boolean = false,
val nudgeResult: String? = null,
// Outcome check-in state
val outcomeSubmitSuccess: Boolean = false,
val outcomeError: String? = null,
val showOutcomeBaselineDialog: Boolean = false,
val showOutcomeFollowUpDialog: Boolean = false,
val outcomeFollowUpDay: OutcomeDay? = null,
/** Non-null when a streak tier was just reached and should be celebrated. */
val streakMilestone: Int? = null,
val unreadActivityCount: Int = 0
)
@HiltViewModel
class HomeViewModel @Inject constructor(
private val questionRepository: QuestionRepository,
private val localAnswerRepository: LocalAnswerRepository,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository,
private val encryptionManager: CoupleEncryptionManager,
private val db: FirebaseFirestore,
private val functions: FirebaseFunctions,
private val questionSessionRepository: QuestionSessionRepository,
private val challengeDataSource: FirestoreChallengeDataSource,
private val capsuleDataSource: FirestoreCapsuleDataSource,
private val datePlanRepository: DatePlanRepository,
private val sealedRevealManager: SealedRevealManager,
private val outcomeRepository: OutcomeRepository,
private val settingsRepository: SettingsRepository,
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource,
private val dailyQuestionResolver: app.closer.domain.usecase.DailyQuestionResolver,
private val dateMemoryDataSource: app.closer.data.remote.FirestoreDateMemoryDataSource,
private val dateReflectionDataSource: app.closer.data.remote.FirestoreDateReflectionDataSource
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private var coupleStateListener: com.google.firebase.firestore.ListenerRegistration? = null
private var partnerAnswerListener: com.google.firebase.firestore.ListenerRegistration? = null
init {
loadHome()
observeAnswers()
observeCoupleState()
observeUnreadActivity()
}
private fun observeUnreadActivity() {
val uid = authRepository.currentUserId ?: return
viewModelScope.launch {
activityDataSource.observeUnreadCount(uid).collect { count ->
_uiState.update { it.copy(unreadActivityCount = count) }
}
}
}
override fun onCleared() {
super.onCleared()
coupleStateListener?.remove()
coupleStateListener = null
partnerAnswerListener?.remove()
partnerAnswerListener = null
}
fun loadHome() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
// Resolve via the shared resolver so Home shows the SAME question as the
// daily-question screen (assignment-backed), keeping answered/waiting state in sync.
val dailyQuestion = dailyQuestionResolver.resolve().question
val categories = questionRepository.getCategories()
.take(6)
.map { category ->
HomeCategorySummary(
category = category,
questionCount = questionRepository.getQuestionCountByCategory(category.id)
)
}
val uid = authRepository.currentUserId
uid?.let { launch { runCatching { sealedRevealManager.ensurePublicKeyPublished(it) } } }
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
val partnerUser = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId ->
runCatching { userRepository.getUser(partnerId) }
.onFailure { Log.w(TAG, "Could not load partner profile", it) }
.getOrNull()
}
val partnerName = partnerUser?.displayName
val partnerPhotoUrl = partnerUser?.photoUrl
val encryptionStatus = couple?.let(encryptionManager::checkStatus)
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY
// Heal profile-metadata encryption once the couple key is available (idempotent + targeted;
// covers pre-pairing-plaintext + legacy users). Skipped when the key isn't present yet.
if (uid != null && encryptionStatus != null && !needsRecovery) {
launch {
runCatching { userRepository.migrateProfileFields(uid) }
.onFailure { Log.w(TAG, "profile encryption migration failed", it) }
}
}
// Outcome check-in due-state calculation
val appSettings = settingsRepository.settings.first()
val outcomeBaselineShownAt = appSettings.outcomeBaselineShownAt
// Streak milestone: celebrate each tier (7/30/100/365) exactly once.
val streak = couple?.streakCount ?: 0
val crossedMilestone = STREAK_MILESTONES
.lastOrNull { streak >= it && appSettings.lastCelebratedStreakMilestone < it }
if (crossedMilestone != null) {
runCatching { settingsRepository.setLastCelebratedStreakMilestone(crossedMilestone) }
}
val outcomes = couple?.let {
runCatching { outcomeRepository.getOutcomes(it.id) }
.onFailure { Log.w(TAG, "Could not load outcomes", it) }
.getOrDefault(emptyList())
} ?: emptyList()
val baselineRecorded = outcomes.any { it.dayKey == OutcomeDay.BASELINE.key }
val showBaselineDialog = couple != null && !baselineRecorded && outcomeBaselineShownAt == 0L
val followUpDay = couple?.let { dueFollowUpDay(it.createdAt, outcomes) }
// Retention signal fetches — run in parallel, failures silently default to false.
var hasWaitingGame = false
var waitingGameRoute: String? = null
var waitingGameType: String? = null
var hasActiveChallenge = false
var hasUpcomingDatePlan = false
var hasUnlockedCapsule = false
var hasPendingDateReflection = false
val coupleId = couple?.id
if (couple != null && coupleId != null && uid != null) {
coroutineScope {
val gameJob = async {
runCatching {
val session = questionSessionRepository.getActiveSessionForCouple(coupleId)
?.takeIf { uid !in it.completedByUsers }
session to gameRouteFor(session?.gameType)
}.getOrDefault(null to null)
}
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)
}
// Pending date reflection: the most recent completed date this user hasn't
// reflected on yet. The nudge chases the latest date; older un-reflected dates
// remain reachable from the Replay timeline.
val reflectionJob = async {
runCatching {
val latest = dateMemoryDataSource.getHistoryOnce(coupleId).firstOrNull()
latest != null &&
!dateReflectionDataSource.hasReflected(coupleId, latest.id, uid)
}.getOrDefault(false)
}
val (waitingSession, waitingRoute) = gameJob.await()
hasWaitingGame = waitingSession != null
waitingGameRoute = waitingRoute
waitingGameType = waitingSession?.gameType
hasActiveChallenge = challengeJob.await()
hasUpcomingDatePlan = dateJob.await()
hasUnlockedCapsule = capsuleJob.await()
hasPendingDateReflection = reflectionJob.await()
}
}
_uiState.update { current ->
current.copy(
isLoading = false,
dailyQuestion = dailyQuestion,
categories = categories,
partnerName = partnerName,
partnerPhotoUrl = partnerPhotoUrl,
streakCount = couple?.streakCount ?: 0,
togetherSince = couple?.createdAt ?: 0L,
isPaired = couple != null,
coupleId = coupleId,
partnerLeftEvent = false,
needsRecovery = needsRecovery,
hasWaitingGame = hasWaitingGame,
waitingGameRoute = waitingGameRoute,
waitingGameType = waitingGameType,
hasActiveChallenge = hasActiveChallenge,
hasUpcomingDatePlan = hasUpcomingDatePlan,
hasUnlockedCapsule = hasUnlockedCapsule,
hasPendingDateReflection = hasPendingDateReflection,
showOutcomeBaselineDialog = showBaselineDialog,
showOutcomeFollowUpDialog = followUpDay != null,
outcomeFollowUpDay = followUpDay,
streakMilestone = crossedMilestone ?: current.streakMilestone
).refreshDailyQuestionState().withHomeActions()
}
observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id)
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
error = e.message ?: "Couldn't load your space right now."
).withHomeActions()
}
}
}
}
private fun observeCoupleState() {
val uid = authRepository.currentUserId ?: return
coupleStateListener?.remove()
coupleStateListener = db.collection("users").document(uid)
.addSnapshotListener(com.google.firebase.firestore.MetadataChanges.INCLUDE) { snapshot, error ->
if (error != null || snapshot == null || !snapshot.exists()) {
return@addSnapshotListener
}
val newCoupleId = snapshot.getString("coupleId")
val oldIsPaired = _uiState.value.isPaired
val oldPartnerName = _uiState.value.partnerName
val newIsPaired = !newCoupleId.isNullOrBlank()
if (newIsPaired != oldIsPaired) {
// Capture partner name before reload so the banner can reference it.
val showPartnerLeftBanner = oldIsPaired && !newIsPaired
if (showPartnerLeftBanner) {
_uiState.update {
it.copy(
isPaired = false,
partnerName = null,
partnerLeftEvent = true
).withHomeActions()
}
}
loadHome()
}
}
}
/**
* Consumes the partner-left banner event. Call from the UI after showing it.
*/
fun consumePartnerLeftEvent() {
_uiState.update { it.copy(partnerLeftEvent = false) }
}
/** Called after the recovery flow completes so the banner goes away. */
fun onRecoveryCompleted() {
_uiState.update { it.copy(needsRecovery = false) }
loadHome()
}
private fun dueFollowUpDay(createdAt: Long, outcomes: List<Outcome>): OutcomeDay? {
val paired = java.time.Instant.ofEpochMilli(createdAt).atZone(ZoneId.systemDefault()).toLocalDate()
val today = LocalDate.now()
val ageDays = java.time.temporal.ChronoUnit.DAYS.between(paired, today)
val due = listOf(
OutcomeDay.DAY_30 to 30,
OutcomeDay.DAY_60 to 60,
OutcomeDay.DAY_90 to 90
).firstOrNull { (_, days) -> ageDays >= days } ?: return null
return if (outcomes.any { it.dayKey == due.first.key }) null else due.first
}
fun submitOutcome(dayKey: String, scores: OutcomeScores) {
viewModelScope.launch {
_uiState.update { it.copy(outcomeSubmitSuccess = false, outcomeError = null) }
try {
val uid = authRepository.currentUserId
?: throw IllegalStateException("Not signed in.")
val couple = coupleRepository.getCoupleForUser(uid)
?: throw IllegalStateException("Not paired.")
outcomeRepository.submitOutcome(couple.id, dayKey, scores).getOrThrow()
settingsRepository.setOutcomeLastPromptedDay(dayKey)
if (dayKey == OutcomeDay.BASELINE.key) {
settingsRepository.markOutcomeBaselineShown()
}
_uiState.update {
it.copy(
outcomeSubmitSuccess = true,
showOutcomeBaselineDialog = false,
showOutcomeFollowUpDialog = false,
outcomeFollowUpDay = null
)
}
loadHome()
} catch (e: Exception) {
_uiState.update { it.copy(outcomeError = e.message ?: "Couldnt save check-in.") }
}
}
}
fun markBaselineOutcomeShown() {
viewModelScope.launch {
settingsRepository.markOutcomeBaselineShown()
_uiState.update { it.copy(showOutcomeBaselineDialog = false) }
}
}
fun markFollowUpOutcomeShown(dayKey: String) {
viewModelScope.launch {
settingsRepository.setOutcomeLastPromptedDay(dayKey)
_uiState.update { it.copy(showOutcomeFollowUpDialog = false, outcomeFollowUpDay = null) }
}
}
fun consumeOutcomeSuccess() = _uiState.update { it.copy(outcomeSubmitSuccess = false) }
fun consumeOutcomeError() = _uiState.update { it.copy(outcomeError = null) }
/**
* Sends a gentle reminder to the partner that the daily question is waiting.
* Notification wiring is intentionally a no-op until Batch 6.
*/
fun sendGentleReminder() {
// Only meaningful when we have answered but partner hasn't.
val state = _uiState.value
if (state.dailyQuestionState != DailyQuestionState.USER_ANSWERED_PARTNER_PENDING) return
viewModelScope.launch {
runCatching {
functions.getHttpsCallable("sendGentleReminderCallable").call().await()
}.onSuccess {
_uiState.update { it.copy(reminderSentEvent = true) }
}.onFailure { e ->
Log.w(TAG, "Gentle reminder failed", e)
}
}
}
fun consumeReminderSentEvent() = _uiState.update { it.copy(reminderSentEvent = false) }
/**
* Send a "thinking of you 💜" nudge to the partner via the callable (rate-limited + quiet-hours-aware
* server-side). Fails gracefully: maps the error to a friendly one-shot message, never crashes, and
* the rest of the sheet works regardless (and before the function is deployed).
*/
fun sendThinkingOfYou() {
val state = _uiState.value
if (!state.isPaired || state.isSendingNudge) return
_uiState.update { it.copy(isSendingNudge = true) }
viewModelScope.launch {
val message = runCatching {
functions.getHttpsCallable("sendThinkingOfYouCallable").call().await()
}.fold(
onSuccess = { "Sent 💜" },
onFailure = { e ->
Log.w(TAG, "Thinking of you failed", e)
if ((e as? FirebaseFunctionsException)?.code ==
FirebaseFunctionsException.Code.RESOURCE_EXHAUSTED
) {
"You've sent a few already — give it a moment 💜"
} else {
"Couldn't send right now. Try again."
}
}
)
_uiState.update { it.copy(isSendingNudge = false, nudgeResult = message) }
}
}
fun consumeNudgeResult() = _uiState.update { it.copy(nudgeResult = null) }
fun consumeStreakMilestone() = _uiState.update { it.copy(streakMilestone = null) }
private fun observeAnswers() {
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(
total = answers.size,
revealed = answers.count { answer -> answer.isRevealed },
private = answers.count { answer -> !answer.isRevealed },
latest = sorted.firstOrNull(),
answeredQuestionIds = answers.map { answer -> answer.questionId }.toSet()
),
weeklyRecapReady = weeklyRecapReady
).refreshDailyQuestionState().withHomeActions()
}
}
}
}
private fun observePartnerAnswer(
coupleId: String?,
coupleUserIds: List<String>?,
dailyQuestionId: String?
) {
partnerAnswerListener?.remove()
partnerAnswerListener = null
val cId = coupleId ?: return
val qId = dailyQuestionId ?: return
val uid = authRepository.currentUserId ?: return
val partnerId = coupleUserIds?.firstOrNull { it != uid } ?: return
val today = FirestoreAnswerDataSource.todayLocalDateString()
partnerAnswerListener = db.collection("couples")
.document(cId)
.collection("daily_question")
.document(today)
.collection("answers")
.document(partnerId)
.addSnapshotListener(com.google.firebase.firestore.MetadataChanges.INCLUDE) { snapshot, error ->
if (error != null) {
Log.w(TAG, "Partner answer listener failed", error)
return@addSnapshotListener
}
val hasPartnerAnswer = snapshot?.exists() == true
_uiState.update {
it.copy(
hasPartnerAnsweredToday = hasPartnerAnswer,
partnerAnsweredQuestionId = if (hasPartnerAnswer) qId else null
).refreshDailyQuestionState().withHomeActions()
}
}
}
private fun HomeUiState.refreshDailyQuestionState(): HomeUiState {
val questionId = dailyQuestion?.id
val userAnswered = questionId != null && questionId in answerStats.answeredQuestionIds
val userRevealed = questionId != null && answerStats.latest?.let { latest ->
latest.questionId == questionId && latest.isRevealed
} == true
val state = when {
questionId == null -> DailyQuestionState.UNANSWERED
userRevealed -> DailyQuestionState.REVEALED
userAnswered && hasPartnerAnsweredToday -> DailyQuestionState.BOTH_ANSWERED
userAnswered -> DailyQuestionState.USER_ANSWERED_PARTNER_PENDING
hasPartnerAnsweredToday -> DailyQuestionState.PARTNER_ANSWERED_USER_PENDING
else -> DailyQuestionState.UNANSWERED
}
return copy(
dailyQuestionState = state,
hasRevealedToday = userRevealed
)
}
private fun HomeUiState.withHomeActions(): HomeUiState {
if (isLoading || error != null) {
return copy(primaryAction = null, secondaryActions = emptyList(), pendingActions = emptyList())
}
val engineInput = PriorityInput(
needsCriticalAction = needsRecovery,
isPaired = isPaired,
needsEncryptionUnlock = needsRecovery,
revealReady = dailyQuestionState == DailyQuestionState.BOTH_ANSWERED,
partnerAnsweredUserPending = dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING,
gameWaiting = hasWaitingGame(),
challengeWaiting = hasIncompleteChallenge(),
dailyQuestionUnanswered = dailyQuestionState == DailyQuestionState.UNANSWERED && dailyQuestion != null,
dailyQuestionAwaitingPartner = dailyQuestionState == DailyQuestionState.USER_ANSWERED_PARTNER_PENDING,
dailyQuestionRevealed = dailyQuestionState == DailyQuestionState.REVEALED && dailyQuestion != null,
weeklyRecapReady = weeklyRecapReady,
capsuleUnlocked = hasUnlockedCapsule(),
dateReminder = hasUpcomingDate(),
dateReflectionPending = hasPendingDateReflection,
suggestedPackAvailable = categories.isNotEmpty(),
exploreGamesAvailable = categories.isNotEmpty()
)
val priorityOutput = HomePriorityEngine.compute(engineInput)
val primary = priorityOutput.primary?.let { toHomeAction(it.priority) }
val secondary = priorityOutput.secondary.mapNotNull { toHomeAction(it.priority) }
// The primary action already gets the prominent hero card; drop it from the "Waiting for
// you" list so the same item isn't surfaced twice (C-HOME-001).
val pending = buildPendingActions().filterNot { pending ->
pending.target == primary?.target ||
(primary?.target == HomeActionTarget.DailyQuestion &&
(pending.target == HomeActionTarget.AnswerReveal ||
pending.target == HomeActionTarget.DailyQuestion))
}
return copy(
primaryAction = primary,
secondaryActions = secondary.take(3),
pendingActions = pending.take(3)
)
}
private fun HomeUiState.toHomeAction(priority: Priority): HomeAction? = when (priority) {
Priority.CRITICAL_ACTION ->
if (needsRecovery) HomeAction(
eyebrow = "Account recovery",
title = "Secure your answers before continuing.",
body = "A privacy action needs your attention. Complete recovery to keep your shared space safe.",
cta = "Start recovery",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
) else null
Priority.PAIRING_NEEDED -> HomeAction(
eyebrow = "1 of 2 connected",
title = "A private space for two",
body = "Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.",
cta = "Invite partner",
target = HomeActionTarget.InvitePartner,
tone = HomeActionTone.Invite
)
Priority.ENCRYPTION_UNLOCK_NEEDED -> HomeAction(
eyebrow = "Encryption unlock",
title = "Unlock your shared answers.",
body = "Your couple's encryption needs to be restored. Complete recovery to keep accessing your answers.",
cta = "Recover keys",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
)
Priority.REVEAL_READY -> buildDailyQuestionAction(
title = "Reveal is ready.",
body = "Both of you answered. Open it together when you are both in the right headspace.",
cta = "Reveal together"
)
Priority.PARTNER_ANSWERED_USER_PENDING -> buildDailyQuestionAction(
title = "Your partner answered. Your turn.",
body = "Answer to unlock the reveal. Your response stays private until you are ready.",
cta = "Answer to unlock reveal"
)
// NOTE: GAME_WAITING fires whenever there's an active session this user hasn't finished
// (for async games like This or That, completedByUsers stays empty until BOTH finish, so we
// cannot assume the partner has played their part here). Keep this copy accurate for every
// state — the real-time "X played their part, your turn" nudge is delivered separately by the
// push-driven YOUR_TURN GamePromptBanner, which knows the partner actually finished first.
Priority.GAME_WAITING -> HomeAction(
eyebrow = "Game in progress",
title = "Pick up your game.",
body = partnerName?.let {
"Jump back in to finish your picks and see how you and $it line up."
} ?: "Jump back in to finish your picks and see how you two line up.",
cta = "Play now",
target = HomeActionTarget.Game,
tone = HomeActionTone.Ritual,
gameRoute = waitingGameRoute
)
Priority.CHALLENGE_WAITING -> HomeAction(
eyebrow = "Connection challenge",
title = "Todays challenge step is ready.",
body = "Open one small shared action for tonight. It is meant to feel doable, not like homework.",
cta = "Open challenge",
target = HomeActionTarget.Challenge,
tone = HomeActionTone.Ritual
)
Priority.DAILY_QUESTION_UNANSWERED -> buildDailyQuestionAction(
title = dailyQuestion?.text ?: "Tonight's question is ready.",
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
cta = "Answer privately"
)
// You answered; the reveal waits on your partner. The hero card (PrimaryHomeActionCard) overrides
// this title/body and routes the CTA to the gentle-reminder send; this copy/CTA label is what shows
// when it renders as a smaller secondary card (a game/challenge is the hero).
Priority.DAILY_QUESTION_AWAITING_PARTNER -> buildDailyQuestionAction(
title = "You showed up tonight.",
body = partnerName?.let { "Your answer stays private until $it answers too — no pressure." }
?: "Your answer stays private until your partner answers too — no pressure.",
cta = "Send a gentle nudge"
)
// You already revealed today — a low-priority closure card that links to the discussion thread.
Priority.DAILY_QUESTION_REVEALED -> buildDailyQuestionAction(
title = "You opened a conversation tonight.",
body = "Keep it going whenever you're both ready.",
cta = "Keep the conversation going"
)
Priority.WEEKLY_RECAP_READY -> HomeAction(
eyebrow = "Your week together",
title = "Look back at what you built this week.",
body = "Reveals, answers, and small rituals are summarized for just the two of you.",
cta = "See recap",
target = HomeActionTarget.AnswerHistory,
tone = HomeActionTone.Reflection
)
Priority.CAPSULE_UNLOCKED -> HomeAction(
eyebrow = "Memory capsule",
title = "A saved memory is ready to open.",
body = "One of your time capsules unlocked. Open it together and remember why you saved it.",
cta = "Open capsule",
target = HomeActionTarget.MemoryCapsule,
tone = HomeActionTone.Reflection
)
Priority.DATE_REMINDER -> HomeAction(
eyebrow = "Date coming up",
title = "A planned moment is almost here.",
body = "You saved a date idea together. Check the details before the night arrives.",
cta = "View date",
target = HomeActionTarget.DatePlan,
tone = HomeActionTone.Ritual
)
Priority.DATE_REFLECTION_PENDING -> HomeAction(
eyebrow = "Date replay",
title = partnerName?.let { "Reflect on your date with $it 💭" }
?: "Reflect on your date 💭",
body = "Capture what the night meant to you. You'll reveal your reflections together when you're both ready.",
cta = "Add your reflection",
target = HomeActionTarget.DateMemories,
tone = HomeActionTone.Reflection
)
Priority.SUGGESTED_PACK -> categories.firstOrNull()?.let { category ->
HomeAction(
eyebrow = "Suggested pack",
title = category.category.displayName.ifBlank { "Question pack" },
body = "${category.questionCount} questions for when you want a different doorway into the conversation.",
cta = "Open pack",
target = HomeActionTarget.QuestionPacks,
tone = HomeActionTone.Pack,
categoryId = category.category.id
)
}
Priority.EXPLORE_GAMES -> HomeAction(
eyebrow = "Explore",
title = "Try a game together.",
body = "Playful ways to connect when you both want something light.",
cta = "Browse games",
target = HomeActionTarget.QuestionPacks,
tone = HomeActionTone.Starter
)
}
private fun HomeUiState.buildDailyQuestionAction(
title: String,
body: String,
cta: String
): HomeAction = HomeAction(
eyebrow = "Your daily question",
title = title,
body = body,
cta = cta,
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { it.isNotBlank() }?.toHomeLabel()
)
private fun HomeUiState.buildPendingActions(): List<PendingActionCard> {
if (!isPaired) return emptyList()
val actions = mutableListOf<PendingActionCard>()
if (dailyQuestionState == DailyQuestionState.BOTH_ANSWERED) {
actions += PendingActionCard(
title = "Reveal is ready",
subtitle = "Both of you answered tonight. Open it together.",
priority = 1,
target = HomeActionTarget.AnswerReveal
)
}
if (dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING) {
actions += PendingActionCard(
title = "Your partner answered",
subtitle = "Answer tonights question to unlock the reveal.",
priority = 2,
target = HomeActionTarget.DailyQuestion
)
}
if (hasWaitingGame()) {
actions += PendingActionCard(
title = "Game waiting",
subtitle = "Your turn to play a game together.",
priority = 3,
target = HomeActionTarget.Game
)
}
if (hasIncompleteChallenge()) {
actions += PendingActionCard(
title = "Challenge waiting",
subtitle = "Todays small step is ready for both of you.",
priority = 4,
target = HomeActionTarget.Challenge
)
}
if (hasUpcomingDate()) {
actions += PendingActionCard(
title = "Date coming up",
subtitle = "A planned moment is almost here.",
priority = 5,
target = HomeActionTarget.DatePlan
)
}
if (hasPendingDateReflection) {
actions += PendingActionCard(
title = "Reflect on your date",
subtitle = "Capture the night, then reveal together.",
priority = 6,
target = HomeActionTarget.DateMemories
)
}
if (hasUnlockedCapsule()) {
actions += PendingActionCard(
title = "Capsule unlocked",
subtitle = "A saved memory is ready to open together.",
priority = 7,
target = HomeActionTarget.MemoryCapsule
)
}
return actions.sortedBy { it.priority }
}
private fun HomeUiState.hasWaitingGame(): Boolean = hasWaitingGame
private fun HomeUiState.hasIncompleteChallenge(): Boolean = hasActiveChallenge
private fun HomeUiState.hasUpcomingDate(): Boolean = hasUpcomingDatePlan
private fun HomeUiState.hasUnlockedCapsule(): Boolean = hasUnlockedCapsule
private fun String.toHomeLabel(): String =
split("_", "-")
.filter { part -> part.isNotBlank() }
.joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } }
companion object {
private const val TAG = "HomeViewModel"
private val STREAK_MILESTONES = listOf(7, 30, 100, 365)
}
}