refactor(home): lift pure action mapper -> HomeActionMapper.kt (VM 867->562)
Part 2b. Moves the ~305 lines of pure HomeUiState action-derivation extensions (refreshDailyQuestionState/withHomeActions internal; toHomeAction/ buildDailyQuestionAction/buildPendingActions/has*/toHomeLabel private) out of HomeViewModel verbatim (de-indented, byte-identical bodies). Verified pure — no this@HomeViewModel/repo refs. VM call-sites unchanged (same-package extensions). compile + full unit suite green. HomeViewModel now 562 lines (from 1030). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
227d141221
commit
7be9a1bf15
|
|
@ -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<PendingActionCard> {
|
||||||
|
if (!isPaired) return emptyList()
|
||||||
|
|
||||||
|
val actions = mutableListOf<PendingActionCard>()
|
||||||
|
|
||||||
|
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() } }
|
||||||
|
|
@ -31,8 +31,6 @@ import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.functions.FirebaseFunctions
|
import com.google.firebase.functions.FirebaseFunctions
|
||||||
import com.google.firebase.functions.FirebaseFunctionsException
|
import com.google.firebase.functions.FirebaseFunctionsException
|
||||||
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.Priority
|
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
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<PendingActionCard> {
|
|
||||||
if (!isPaired) return emptyList()
|
|
||||||
|
|
||||||
val actions = mutableListOf<PendingActionCard>()
|
|
||||||
|
|
||||||
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 {
|
companion object {
|
||||||
private const val TAG = "HomeViewModel"
|
private const val TAG = "HomeViewModel"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue