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.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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue