feat(date-memories): add Home nudge for pending date reflection + priority engine (batch 3/8)

This commit is contained in:
null 2026-06-30 16:51:55 -05:00
parent 15087df13b
commit 4ecb1560cb
4 changed files with 122 additions and 12 deletions

View File

@ -19,8 +19,9 @@ package app.closer.ui.home
* 9. Weekly recap ready * 9. Weekly recap ready
* 10. Capsule unlocked * 10. Capsule unlocked
* 11. Date reminder * 11. Date reminder
* 12. Suggested pack * 12. Date reflection pending
* 13. Explore games * 13. Suggested pack
* 14. Explore games
* *
* Rules: * Rules:
* - Show one primary CTA. * - Show one primary CTA.
@ -49,6 +50,7 @@ object HomePriorityEngine {
val weeklyRecapReady: Boolean = false, val weeklyRecapReady: Boolean = false,
val capsuleUnlocked: Boolean = false, val capsuleUnlocked: Boolean = false,
val dateReminder: Boolean = false, val dateReminder: Boolean = false,
val dateReflectionPending: Boolean = false,
val suggestedPackAvailable: Boolean = false, val suggestedPackAvailable: Boolean = false,
val exploreGamesAvailable: Boolean = false val exploreGamesAvailable: Boolean = false
) )
@ -68,6 +70,9 @@ object HomePriorityEngine {
WEEKLY_RECAP_READY, WEEKLY_RECAP_READY,
CAPSULE_UNLOCKED, CAPSULE_UNLOCKED,
DATE_REMINDER, 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. // You already revealed today's question — a low-priority "keep the conversation going" closure card.
DAILY_QUESTION_REVEALED, DAILY_QUESTION_REVEALED,
SUGGESTED_PACK, SUGGESTED_PACK,
@ -141,6 +146,7 @@ object HomePriorityEngine {
Priority.WEEKLY_RECAP_READY -> input.weeklyRecapReady Priority.WEEKLY_RECAP_READY -> input.weeklyRecapReady
Priority.CAPSULE_UNLOCKED -> input.capsuleUnlocked Priority.CAPSULE_UNLOCKED -> input.capsuleUnlocked
Priority.DATE_REMINDER -> input.dateReminder Priority.DATE_REMINDER -> input.dateReminder
Priority.DATE_REFLECTION_PENDING -> input.dateReflectionPending
Priority.SUGGESTED_PACK -> input.suggestedPackAvailable Priority.SUGGESTED_PACK -> input.suggestedPackAvailable
Priority.EXPLORE_GAMES -> input.exploreGamesAvailable Priority.EXPLORE_GAMES -> input.exploreGamesAvailable
} }
@ -166,7 +172,8 @@ object HomePriorityEngine {
Priority.DAILY_QUESTION_AWAITING_PARTNER, Priority.DAILY_QUESTION_AWAITING_PARTNER,
Priority.DAILY_QUESTION_REVEALED, Priority.DAILY_QUESTION_REVEALED,
Priority.WEEKLY_RECAP_READY, Priority.WEEKLY_RECAP_READY,
Priority.DATE_REMINDER -> true Priority.DATE_REMINDER,
Priority.DATE_REFLECTION_PENDING -> true
else -> false else -> false
} }
} }

View File

@ -251,6 +251,7 @@ private fun HomeCallbacks.toActionHandler(onNavigate: (String) -> Unit): (HomeAc
HomeActionTarget.Challenge -> onNavigate(AppRoute.CONNECTION_CHALLENGES) HomeActionTarget.Challenge -> onNavigate(AppRoute.CONNECTION_CHALLENGES)
HomeActionTarget.DatePlan -> onNavigate(AppRoute.DATE_MATCHES) HomeActionTarget.DatePlan -> onNavigate(AppRoute.DATE_MATCHES)
HomeActionTarget.MemoryCapsule -> onNavigate(AppRoute.MEMORY_LANE) 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.Challenge -> R.drawable.glyph_connection_challenge
HomeActionTarget.DatePlan -> R.drawable.glyph_date_card_heart HomeActionTarget.DatePlan -> R.drawable.glyph_date_card_heart
HomeActionTarget.MemoryCapsule -> R.drawable.glyph_memory_capsule HomeActionTarget.MemoryCapsule -> R.drawable.glyph_memory_capsule
HomeActionTarget.DateMemories -> R.drawable.glyph_date_replay
} }
@Composable @Composable
@ -715,6 +717,16 @@ private fun PartnerQuickActionsSheet(
if (state.togetherSince > 0L) add("together since ${formatMonthYear(state.togetherSince)}") if (state.togetherSince > 0L) add("together since ${formatMonthYear(state.togetherSince)}")
}.joinToString(" · ") }.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() } val openPartnerPage = { onNavigate(AppRoute.PARTNER_HOME); onDismiss() }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
@ -757,26 +769,36 @@ private fun PartnerQuickActionsSheet(
overflow = TextOverflow.Ellipsis 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) Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 0.5.dp)
PartnerSheetAction("💜", "Thinking of you", enabled = !state.isSendingNudge, onClick = onThinkingOfYou) PartnerSheetAction(R.drawable.glyph_heart, "Thinking of you", enabled = !state.isSendingNudge, onClick = onThinkingOfYou)
PartnerSheetAction("💬", "Message") { onNavigate(AppRoute.MESSAGES); onDismiss() } PartnerSheetAction(R.drawable.glyph_chat, "Message") { onNavigate(AppRoute.MESSAGES); onDismiss() }
PartnerSheetAction( PartnerSheetAction(
"", R.drawable.glyph_paired_cards,
"Together", "Together",
trailing = state.unreadActivityCount.takeIf { it > 0 }?.let { if (it > 9) "9+" else "$it" } trailing = state.unreadActivityCount.takeIf { it > 0 }?.let { if (it > 9) "9+" else "$it" }
) { onNavigate(AppRoute.ACTIVITY); onDismiss() } ) { 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 @Composable
private fun PartnerSheetAction( private fun PartnerSheetAction(
emoji: String, @DrawableRes iconRes: Int,
label: String, label: String,
enabled: Boolean = true, enabled: Boolean = true,
trailing: String? = null, trailing: String? = null,
@ -791,7 +813,12 @@ private fun PartnerSheetAction(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically 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(
text = label, text = label,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),

View File

@ -70,7 +70,8 @@ enum class HomeActionTarget {
Game, Game,
Challenge, Challenge,
DatePlan, DatePlan,
MemoryCapsule MemoryCapsule,
DateMemories
} }
enum class HomeActionTone { enum class HomeActionTone {
@ -158,6 +159,8 @@ data class HomeUiState(
val hasActiveChallenge: Boolean = false, val hasActiveChallenge: Boolean = false,
val hasUpcomingDatePlan: Boolean = false, val hasUpcomingDatePlan: Boolean = false,
val hasUnlockedCapsule: 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 weeklyRecapReady: Boolean = false,
val reminderSentEvent: Boolean = false, val reminderSentEvent: Boolean = false,
/** "Thinking of you" nudge: in-flight guard + one-shot snackbar message (success or friendly error). */ /** "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 outcomeRepository: OutcomeRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource, 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() { ) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
@ -289,6 +294,7 @@ class HomeViewModel @Inject constructor(
var hasActiveChallenge = false var hasActiveChallenge = false
var hasUpcomingDatePlan = false var hasUpcomingDatePlan = false
var hasUnlockedCapsule = false var hasUnlockedCapsule = false
var hasPendingDateReflection = false
val coupleId = couple?.id val coupleId = couple?.id
if (couple != null && coupleId != null && uid != null) { if (couple != null && coupleId != null && uid != null) {
coroutineScope { coroutineScope {
@ -320,6 +326,16 @@ class HomeViewModel @Inject constructor(
.any { it.status == "sealed" && it.unlockAt in 1L..now } .any { it.status == "sealed" && it.unlockAt in 1L..now }
}.getOrDefault(false) }.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() val (waitingSession, waitingRoute) = gameJob.await()
hasWaitingGame = waitingSession != null hasWaitingGame = waitingSession != null
waitingGameRoute = waitingRoute waitingGameRoute = waitingRoute
@ -327,6 +343,7 @@ class HomeViewModel @Inject constructor(
hasActiveChallenge = challengeJob.await() hasActiveChallenge = challengeJob.await()
hasUpcomingDatePlan = dateJob.await() hasUpcomingDatePlan = dateJob.await()
hasUnlockedCapsule = capsuleJob.await() hasUnlockedCapsule = capsuleJob.await()
hasPendingDateReflection = reflectionJob.await()
} }
} }
@ -349,6 +366,7 @@ class HomeViewModel @Inject constructor(
hasActiveChallenge = hasActiveChallenge, hasActiveChallenge = hasActiveChallenge,
hasUpcomingDatePlan = hasUpcomingDatePlan, hasUpcomingDatePlan = hasUpcomingDatePlan,
hasUnlockedCapsule = hasUnlockedCapsule, hasUnlockedCapsule = hasUnlockedCapsule,
hasPendingDateReflection = hasPendingDateReflection,
showOutcomeBaselineDialog = showBaselineDialog, showOutcomeBaselineDialog = showBaselineDialog,
showOutcomeFollowUpDialog = followUpDay != null, showOutcomeFollowUpDialog = followUpDay != null,
outcomeFollowUpDay = followUpDay, outcomeFollowUpDay = followUpDay,
@ -619,6 +637,7 @@ class HomeViewModel @Inject constructor(
weeklyRecapReady = weeklyRecapReady, weeklyRecapReady = weeklyRecapReady,
capsuleUnlocked = hasUnlockedCapsule(), capsuleUnlocked = hasUnlockedCapsule(),
dateReminder = hasUpcomingDate(), dateReminder = hasUpcomingDate(),
dateReflectionPending = hasPendingDateReflection,
suggestedPackAvailable = categories.isNotEmpty(), suggestedPackAvailable = categories.isNotEmpty(),
exploreGamesAvailable = categories.isNotEmpty() exploreGamesAvailable = categories.isNotEmpty()
) )
@ -759,6 +778,16 @@ class HomeViewModel @Inject constructor(
tone = HomeActionTone.Ritual 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 -> Priority.SUGGESTED_PACK -> categories.firstOrNull()?.let { category ->
HomeAction( HomeAction(
eyebrow = "Suggested pack", 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()) { if (hasUnlockedCapsule()) {
actions += PendingActionCard( actions += PendingActionCard(
title = "Capsule unlocked", title = "Capsule unlocked",
subtitle = "A saved memory is ready to open together.", subtitle = "A saved memory is ready to open together.",
priority = 6, priority = 7,
target = HomeActionTarget.MemoryCapsule target = HomeActionTarget.MemoryCapsule
) )
} }

View File

@ -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 @Test
fun `primary priority convenience returns pairing needed for default empty input`() { fun `primary priority convenience returns pairing needed for default empty input`() {
assertEquals(Priority.PAIRING_NEEDED, HomePriorityEngine.primaryPriority(Input())) assertEquals(Priority.PAIRING_NEEDED, HomePriorityEngine.primaryPriority(Input()))