refactor(home): extract data models -> HomeModels.kt (VM 1030->867)

Part 2a. Moves the Home data classes/enums + computeDailyQuestionState (kept
@VisibleForTesting internal) + gameRouteFor (widened private->internal, its only
caller loadHome is same-package) out of HomeViewModel into HomeModels.kt.
Same package -> no consumer import churn (AppNavigation, DailyQuestionStateTest
unchanged). Behavior-identical. compile + ui.home unit tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-11 20:38:29 -05:00
parent 415f8d6866
commit 227d141221
2 changed files with 178 additions and 163 deletions

View File

@ -0,0 +1,178 @@
package app.closer.ui.home
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.OutcomeDay
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
/**
* Home screen data models + pure state helpers, extracted from HomeViewModel.kt so the UI, the
* action mapper ([HomeActionMapper]), and tests can share them without pulling in the Firestore-bound
* ViewModel. Behavior-identical to the originals.
*/
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,
WeeklyRecap
}
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).
*/
internal 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
}
/**
* Pure derivation of the daily-question home state extracted so it can be unit-tested directly,
* mirroring the pure-logic style of [HomePriorityEngine].
*
* The partner is only counted as "answered" when their answer is for THIS question. The partner-answer
* listener keys off *today's date*, not the question id, so a rotated daily question (e.g. across
* midnight while Home is open) could otherwise mark the pair as both-answered for a question the partner
* never answered. A blank [partnerAnsweredQuestionId] (legacy answer docs written before the field
* existed) falls back to plain existence so the common case never regresses.
*/
@androidx.annotation.VisibleForTesting
internal fun computeDailyQuestionState(
questionId: String?,
answeredQuestionIds: Set<String>,
latestAnswer: LocalAnswer?,
hasPartnerAnsweredToday: Boolean,
partnerAnsweredQuestionId: String?
): DailyQuestionState {
val userAnswered = questionId != null && questionId in answeredQuestionIds
val userRevealed = questionId != null &&
latestAnswer?.let { it.questionId == questionId && it.isRevealed } == true
val partnerAnswered = hasPartnerAnsweredToday &&
(partnerAnsweredQuestionId == questionId || partnerAnsweredQuestionId.isNullOrBlank())
return when {
questionId == null -> DailyQuestionState.UNANSWERED
userRevealed -> DailyQuestionState.REVEALED
userAnswered && partnerAnswered -> DailyQuestionState.BOTH_ANSWERED
userAnswered -> DailyQuestionState.USER_ANSWERED_PARTNER_PENDING
partnerAnswered -> DailyQuestionState.PARTNER_ANSWERED_USER_PENDING
else -> DailyQuestionState.UNANSWERED
}
}
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
)

View File

@ -49,169 +49,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first 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,
WeeklyRecap
}
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
}
/**
* Pure derivation of the daily-question home state extracted so it can be unit-tested directly,
* mirroring the pure-logic style of [HomePriorityEngine].
*
* The partner is only counted as "answered" when their answer is for THIS question. The partner-answer
* listener keys off *today's date*, not the question id, so a rotated daily question (e.g. across
* midnight while Home is open) could otherwise mark the pair as both-answered for a question the partner
* never answered. A blank [partnerAnsweredQuestionId] (legacy answer docs written before the field
* existed) falls back to plain existence so the common case never regresses.
*/
@androidx.annotation.VisibleForTesting
internal fun computeDailyQuestionState(
questionId: String?,
answeredQuestionIds: Set<String>,
latestAnswer: LocalAnswer?,
hasPartnerAnsweredToday: Boolean,
partnerAnsweredQuestionId: String?
): DailyQuestionState {
val userAnswered = questionId != null && questionId in answeredQuestionIds
val userRevealed = questionId != null &&
latestAnswer?.let { it.questionId == questionId && it.isRevealed } == true
val partnerAnswered = hasPartnerAnsweredToday &&
(partnerAnsweredQuestionId == questionId || partnerAnsweredQuestionId.isNullOrBlank())
return when {
questionId == null -> DailyQuestionState.UNANSWERED
userRevealed -> DailyQuestionState.REVEALED
userAnswered && partnerAnswered -> DailyQuestionState.BOTH_ANSWERED
userAnswered -> DailyQuestionState.USER_ANSWERED_PARTNER_PENDING
partnerAnswered -> DailyQuestionState.PARTNER_ANSWERED_USER_PENDING
else -> DailyQuestionState.UNANSWERED
}
}
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 @HiltViewModel
class HomeViewModel @Inject constructor( class HomeViewModel @Inject constructor(