diff --git a/app/src/main/java/app/closer/ui/home/HomeActionMapper.kt b/app/src/main/java/app/closer/ui/home/HomeActionMapper.kt new file mode 100644 index 00000000..03e92616 --- /dev/null +++ b/app/src/main/java/app/closer/ui/home/HomeActionMapper.kt @@ -0,0 +1,318 @@ +package app.closer.ui.home + +import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput +import app.closer.ui.home.HomePriorityEngine.Priority + +/** + * Pure Home action-derivation logic, lifted verbatim from HomeViewModel as top-level HomeUiState + * extensions so it can be unit-tested directly (like HomePriorityEngine). No Firestore/ViewModel + * state — every function derives new HomeUiState/HomeAction values from the receiver. The two entry + * points the ViewModel calls (refreshDailyQuestionState, withHomeActions) are `internal`; the rest + * are file-private helpers. + */ + +internal fun HomeUiState.refreshDailyQuestionState(): HomeUiState { + val state = computeDailyQuestionState( + questionId = dailyQuestion?.id, + answeredQuestionIds = answerStats.answeredQuestionIds, + latestAnswer = answerStats.latest, + hasPartnerAnsweredToday = hasPartnerAnsweredToday, + partnerAnsweredQuestionId = partnerAnsweredQuestionId + ) + return copy( + dailyQuestionState = state, + hasRevealedToday = state == DailyQuestionState.REVEALED + ) +} + +internal 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 = "Your reveal is waiting", + body = "You both answered — open it together when you're ready.", + 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 = "Today’s 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.WeeklyRecap, + 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 { + if (!isPaired) return emptyList() + + val actions = mutableListOf() + + if (dailyQuestionState == DailyQuestionState.BOTH_ANSWERED) { + actions += PendingActionCard( + title = "Your reveal is waiting", + 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 tonight’s 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 = "Today’s 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() } + // Internal format tokens (e.g. the "mc" in daily_fun_mc) must never face users. + .filterNot { part -> part.lowercase() in setOf("mc") } + .joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } } 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 9bdee8e7..f85834a7 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -31,8 +31,6 @@ 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 @@ -550,311 +548,6 @@ class HomeViewModel @Inject constructor( } } - private fun HomeUiState.refreshDailyQuestionState(): HomeUiState { - val state = computeDailyQuestionState( - questionId = dailyQuestion?.id, - answeredQuestionIds = answerStats.answeredQuestionIds, - latestAnswer = answerStats.latest, - hasPartnerAnsweredToday = hasPartnerAnsweredToday, - partnerAnsweredQuestionId = partnerAnsweredQuestionId - ) - return copy( - dailyQuestionState = state, - hasRevealedToday = state == DailyQuestionState.REVEALED - ) - } - - 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 = "Your reveal is waiting", - body = "You both answered — open it together when you're ready.", - 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 = "Today’s 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.WeeklyRecap, - 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 { - if (!isPaired) return emptyList() - - val actions = mutableListOf() - - if (dailyQuestionState == DailyQuestionState.BOTH_ANSWERED) { - actions += PendingActionCard( - title = "Your reveal is waiting", - 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 tonight’s 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 = "Today’s 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() } - // Internal format tokens (e.g. the "mc" in daily_fun_mc) must never face users. - .filterNot { part -> part.lowercase() in setOf("mc") } - .joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } } companion object { private const val TAG = "HomeViewModel"