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
* 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
}
}

View File

@ -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),

View File

@ -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
)
}

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