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:
parent
415f8d6866
commit
227d141221
|
|
@ -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
|
||||
)
|
||||
|
|
@ -49,169 +49,6 @@ 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,
|
||||
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
|
||||
class HomeViewModel @Inject constructor(
|
||||
|
|
|
|||
Loading…
Reference in New Issue