diff --git a/app/src/main/java/app/closer/ui/home/HomeModels.kt b/app/src/main/java/app/closer/ui/home/HomeModels.kt new file mode 100644 index 00000000..cc25b060 --- /dev/null +++ b/app/src/main/java/app/closer/ui/home/HomeModels.kt @@ -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 = 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, + 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 = 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 = 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 = 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 +) 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 574783f7..9bdee8e7 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -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 = 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, - 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 = 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 = 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 = 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(