diff --git a/app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt b/app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt index 78ca9b11..dede3814 100644 --- a/app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt +++ b/app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt @@ -19,8 +19,9 @@ package app.closer.ui.home * 9. Weekly recap ready * 10. Capsule unlocked * 11. Date reminder - * 12. Suggested pack - * 13. Explore games + * 12. Date reflection pending + * 13. Suggested pack + * 14. Explore games * * Rules: * - Show one primary CTA. @@ -49,6 +50,7 @@ object HomePriorityEngine { val weeklyRecapReady: Boolean = false, val capsuleUnlocked: Boolean = false, val dateReminder: Boolean = false, + val dateReflectionPending: Boolean = false, val suggestedPackAvailable: Boolean = false, val exploreGamesAvailable: Boolean = false ) @@ -68,6 +70,9 @@ object HomePriorityEngine { WEEKLY_RECAP_READY, CAPSULE_UNLOCKED, DATE_REMINDER, + // You marked a date done but haven't reflected yet. Value action in the date band: an actionable + // shared-memory prompt, ranked above the daily-question closure card. + DATE_REFLECTION_PENDING, // You already revealed today's question โ€” a low-priority "keep the conversation going" closure card. DAILY_QUESTION_REVEALED, SUGGESTED_PACK, @@ -141,6 +146,7 @@ object HomePriorityEngine { Priority.WEEKLY_RECAP_READY -> input.weeklyRecapReady Priority.CAPSULE_UNLOCKED -> input.capsuleUnlocked Priority.DATE_REMINDER -> input.dateReminder + Priority.DATE_REFLECTION_PENDING -> input.dateReflectionPending Priority.SUGGESTED_PACK -> input.suggestedPackAvailable Priority.EXPLORE_GAMES -> input.exploreGamesAvailable } @@ -166,7 +172,8 @@ object HomePriorityEngine { Priority.DAILY_QUESTION_AWAITING_PARTNER, Priority.DAILY_QUESTION_REVEALED, Priority.WEEKLY_RECAP_READY, - Priority.DATE_REMINDER -> true + Priority.DATE_REMINDER, + Priority.DATE_REFLECTION_PENDING -> true else -> false } } diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index a81d1a90..eb18e986 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -251,6 +251,7 @@ private fun HomeCallbacks.toActionHandler(onNavigate: (String) -> Unit): (HomeAc HomeActionTarget.Challenge -> onNavigate(AppRoute.CONNECTION_CHALLENGES) HomeActionTarget.DatePlan -> onNavigate(AppRoute.DATE_MATCHES) HomeActionTarget.MemoryCapsule -> onNavigate(AppRoute.MEMORY_LANE) + HomeActionTarget.DateMemories -> onNavigate(AppRoute.DATE_MEMORIES) } } @@ -416,6 +417,7 @@ private fun homeActionGlyph(target: HomeActionTarget): Int = when (target) { HomeActionTarget.Challenge -> R.drawable.glyph_connection_challenge HomeActionTarget.DatePlan -> R.drawable.glyph_date_card_heart HomeActionTarget.MemoryCapsule -> R.drawable.glyph_memory_capsule + HomeActionTarget.DateMemories -> R.drawable.glyph_date_replay } @Composable @@ -715,6 +717,16 @@ private fun PartnerQuickActionsSheet( if (state.togetherSince > 0L) add("together since ${formatMonthYear(state.togetherSince)}") }.joinToString(" ยท ") } + // Living "today" status โ€” reflects where the couple is in the daily ritual. Hidden when there's no + // question assigned (no misleading "still open"). + val todayStatus = state.dailyQuestion?.let { + when (state.dailyQuestionState) { + DailyQuestionState.BOTH_ANSWERED, DailyQuestionState.REVEALED -> "You've both answered today ๐Ÿ’œ" + DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "They answered โ€” your turn" + DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "Waiting on their answer" + DailyQuestionState.UNANSWERED -> "Tonight's question is still open" + } + } val openPartnerPage = { onNavigate(AppRoute.PARTNER_HOME); onDismiss() } ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { @@ -757,26 +769,36 @@ private fun PartnerQuickActionsSheet( overflow = TextOverflow.Ellipsis ) } + todayStatus?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 0.5.dp) - PartnerSheetAction("๐Ÿ’œ", "Thinking of you", enabled = !state.isSendingNudge, onClick = onThinkingOfYou) - PartnerSheetAction("๐Ÿ’ฌ", "Message") { onNavigate(AppRoute.MESSAGES); onDismiss() } + PartnerSheetAction(R.drawable.glyph_heart, "Thinking of you", enabled = !state.isSendingNudge, onClick = onThinkingOfYou) + PartnerSheetAction(R.drawable.glyph_chat, "Message") { onNavigate(AppRoute.MESSAGES); onDismiss() } PartnerSheetAction( - "โœจ", + R.drawable.glyph_paired_cards, "Together", trailing = state.unreadActivityCount.takeIf { it > 0 }?.let { if (it > 9) "9+" else "$it" } ) { onNavigate(AppRoute.ACTIVITY); onDismiss() } - PartnerSheetAction("โš™๏ธ", "Your relationship") { onNavigate(AppRoute.RELATIONSHIP_SETTINGS); onDismiss() } + PartnerSheetAction(R.drawable.glyph_memory_capsule, "Our memories") { onNavigate(AppRoute.MEMORY_LANE); onDismiss() } + PartnerSheetAction(R.drawable.glyph_settings, "Your relationship") { onNavigate(AppRoute.RELATIONSHIP_SETTINGS); onDismiss() } } } } @Composable private fun PartnerSheetAction( - emoji: String, + @DrawableRes iconRes: Int, label: String, enabled: Boolean = true, trailing: String? = null, @@ -791,7 +813,12 @@ private fun PartnerSheetAction( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically ) { - Text(text = emoji, style = MaterialTheme.typography.titleMedium) + HomeGlyphIcon( + resId = iconRes, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) Text( text = label, modifier = Modifier.weight(1f), 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 b945c8f3..19cc114e 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -70,7 +70,8 @@ enum class HomeActionTarget { Game, Challenge, DatePlan, - MemoryCapsule + MemoryCapsule, + DateMemories } enum class HomeActionTone { @@ -158,6 +159,8 @@ data class HomeUiState( 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). */ @@ -192,7 +195,9 @@ class HomeViewModel @Inject constructor( private val outcomeRepository: OutcomeRepository, private val settingsRepository: SettingsRepository, private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource, - private val dailyQuestionResolver: app.closer.domain.usecase.DailyQuestionResolver + private val dailyQuestionResolver: app.closer.domain.usecase.DailyQuestionResolver, + private val dateMemoryDataSource: app.closer.data.remote.FirestoreDateMemoryDataSource, + private val dateReflectionDataSource: app.closer.data.remote.FirestoreDateReflectionDataSource ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) @@ -289,6 +294,7 @@ class HomeViewModel @Inject constructor( var hasActiveChallenge = false var hasUpcomingDatePlan = false var hasUnlockedCapsule = false + var hasPendingDateReflection = false val coupleId = couple?.id if (couple != null && coupleId != null && uid != null) { coroutineScope { @@ -320,6 +326,16 @@ class HomeViewModel @Inject constructor( .any { it.status == "sealed" && it.unlockAt in 1L..now } }.getOrDefault(false) } + // Pending date reflection: the most recent completed date this user hasn't + // reflected on yet. The nudge chases the latest date; older un-reflected dates + // remain reachable from the Replay timeline. + val reflectionJob = async { + runCatching { + val latest = dateMemoryDataSource.getHistoryOnce(coupleId).firstOrNull() + latest != null && + !dateReflectionDataSource.hasReflected(coupleId, latest.id, uid) + }.getOrDefault(false) + } val (waitingSession, waitingRoute) = gameJob.await() hasWaitingGame = waitingSession != null waitingGameRoute = waitingRoute @@ -327,6 +343,7 @@ class HomeViewModel @Inject constructor( hasActiveChallenge = challengeJob.await() hasUpcomingDatePlan = dateJob.await() hasUnlockedCapsule = capsuleJob.await() + hasPendingDateReflection = reflectionJob.await() } } @@ -349,6 +366,7 @@ class HomeViewModel @Inject constructor( hasActiveChallenge = hasActiveChallenge, hasUpcomingDatePlan = hasUpcomingDatePlan, hasUnlockedCapsule = hasUnlockedCapsule, + hasPendingDateReflection = hasPendingDateReflection, showOutcomeBaselineDialog = showBaselineDialog, showOutcomeFollowUpDialog = followUpDay != null, outcomeFollowUpDay = followUpDay, @@ -619,6 +637,7 @@ class HomeViewModel @Inject constructor( weeklyRecapReady = weeklyRecapReady, capsuleUnlocked = hasUnlockedCapsule(), dateReminder = hasUpcomingDate(), + dateReflectionPending = hasPendingDateReflection, suggestedPackAvailable = categories.isNotEmpty(), exploreGamesAvailable = categories.isNotEmpty() ) @@ -759,6 +778,16 @@ class HomeViewModel @Inject constructor( 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", @@ -845,11 +874,20 @@ class HomeViewModel @Inject constructor( ) } + 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 = 6, + priority = 7, target = HomeActionTarget.MemoryCapsule ) } diff --git a/app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt b/app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt index 9e40b7f7..bd305982 100644 --- a/app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt +++ b/app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt @@ -318,6 +318,44 @@ class HomePriorityEngineTest { ) } + @Test + fun `date reflection pending is a value action that surfaces as a secondary card`() { + val input = Input( + isPaired = true, + dailyQuestionUnanswered = true, + dateReflectionPending = true, + suggestedPackAvailable = true, + exploreGamesAvailable = true + ) + + val output = HomePriorityEngine.compute(input) + + // Daily question is the hero; the pending reflection rides along as a value-action card, + // while the generic browse items (pack/explore) are filtered out of the secondary band. + assertEquals(Priority.DAILY_QUESTION_UNANSWERED, output.primary?.priority) + assertEquals( + listOf(Priority.DATE_REFLECTION_PENDING), + output.secondary.map { it.priority } + ) + } + + @Test + fun `date reflection pending outranks the daily question closure card`() { + val input = Input( + isPaired = true, + dateReflectionPending = true, + dailyQuestionRevealed = true + ) + + val output = HomePriorityEngine.compute(input) + + assertEquals(Priority.DATE_REFLECTION_PENDING, output.primary?.priority) + assertEquals( + listOf(Priority.DAILY_QUESTION_REVEALED), + output.secondary.map { it.priority } + ) + } + @Test fun `primary priority convenience returns pairing needed for default empty input`() { assertEquals(Priority.PAIRING_NEEDED, HomePriorityEngine.primaryPriority(Input()))