feat(games): game-loop retention batch C3 from UX review

- NextBeatCard: a self-contained 'what's next' card under every game's results
  CTAs so finishing a game doesn't dead-end — shows 'Tonight's question is ready
  →' when today's daily is unanswered, else a 'Day N together' streak note. Own
  @HiltViewModel (DailyQuestionResolver + LocalAnswerRepository + couple), so the
  four game screens only drop in the composable. Verified live on How Well results.
- How Well role swap: the guesser's results now lead with 'Your turn — let {name}
  guess you', starting a new round where they become the subject (Newlywed-style
  reversal; starter == subject). Verified live.
- Date Match: deterministic per-couple deck shuffle (kills 'every couple sees the
  same first card') + skip already-swiped ideas so the deck resumes past them
  instead of restarting at card 1. Verified live (swiped card no longer reappears).
- Desire Sync: remember served question ids per couple (SeenDesireQuestionStore on
  the settings DataStore) so back-to-back rounds don't repeat prompts; clears the
  record to cycle the pool when the unseen remainder can't fill a round.
- How Well pool purity: exclude daily_fun_mc novelty MCs from getQuestionsForPrediction
  (1869 real prediction questions remain); DAO method has no other caller.

Unit suite green; assembleDebug clean; live-verified on the emulator pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-07 21:42:39 -05:00
parent 9d0a0c7fdf
commit cf8f82357e
8 changed files with 278 additions and 19 deletions

View File

@ -58,7 +58,9 @@ interface QuestionDao {
@Query("SELECT * FROM question WHERE type = :type AND status = 'active' AND TRIM(text) <> ''")
suspend fun getQuestionsByType(type: String): List<QuestionEntity>
@Query("SELECT * FROM question WHERE type IN ('single_choice', 'this_or_that', 'scale') AND status = 'active' AND TRIM(text) <> ''")
// Excludes daily_fun_mc: those are throwaway novelty MCs ("pick a laugh-track move") that
// dilute How Well's partner-knowledge premise. Leaves 1869 real prediction questions.
@Query("SELECT * FROM question WHERE type IN ('single_choice', 'this_or_that', 'scale') AND category_id <> 'daily_fun_mc' AND status = 'active' AND TRIM(text) <> ''")
suspend fun getQuestionsForPrediction(): List<QuestionEntity>
@Query("SELECT * FROM question WHERE sex = :sex AND status = 'active' AND TRIM(text) <> ''")

View File

@ -0,0 +1,37 @@
package app.closer.data.local
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Remembers which Desire Sync questions a couple has already been served, so consecutive rounds
* don't repeat prompts out of the ~100-item binary pool. Local-only and creator-side: the joiner
* plays the creator's exact set, so only the couple's shared history matters and there's nothing
* to sync. When the unseen remainder can't fill a round, the record is cleared to start a fresh
* cycle through the pool.
*/
@Singleton
class SeenDesireQuestionStore @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
private fun keyFor(coupleId: String) = stringSetPreferencesKey("seen_desire_$coupleId")
suspend fun seen(coupleId: String): Set<String> =
dataStore.data.map { it[keyFor(coupleId)] ?: emptySet() }.first()
suspend fun markSeen(coupleId: String, ids: Collection<String>) {
dataStore.edit { prefs ->
prefs[keyFor(coupleId)] = (prefs[keyFor(coupleId)] ?: emptySet()) + ids
}
}
suspend fun clear(coupleId: String) {
dataStore.edit { it.remove(keyFor(coupleId)) }
}
}

View File

@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.random.Random
data class DateMatchUiState(
val isLoading: Boolean = true,
@ -39,6 +40,8 @@ data class DateMatchUiState(
val ownSwipes: Map<String, SwipeAction> = emptyMap(),
val matches: List<DateMatch> = emptyList(),
val currentIndex: Int = 0,
/** Set once the initial deck has been filtered against this couple's prior swipes. */
val swipeFilterApplied: Boolean = false,
val justMatched: DateMatch? = null,
val hasPremium: Boolean = false
) {
@ -87,7 +90,11 @@ class DateMatchViewModel @Inject constructor(
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
.getOrNull()
}
val ideas = repository.getDateIdeas()
// Deterministic per-couple order: kills the "every couple's first card is the
// same seed idea" determinism, while staying stable across reloads for one couple.
val ideas = couple?.id
?.let { repository.getDateIdeas().shuffled(Random(it.hashCode())) }
?: repository.getDateIdeas()
_uiState.value = DateMatchUiState(
isLoading = false,
coupleId = couple?.id,
@ -124,9 +131,20 @@ class DateMatchViewModel @Inject constructor(
matches.firstOrNull { it.id !in known }
}
knownMatchIds = matches.mapTo(mutableSetOf()) { it.id }
val swipeMap = swipes.associate { it.dateIdeaId to it.action }
_uiState.update { state ->
// First time swipes arrive, resume the deck PAST ideas already swiped so
// they don't reappear on every reopen (deck used to restart at index 0).
val (deck, index, applied) = if (!state.swipeFilterApplied && swipeMap.isNotEmpty()) {
Triple(state.dateIdeas.filterNot { it.id in swipeMap.keys }, 0, true)
} else {
Triple(state.dateIdeas, state.currentIndex, state.swipeFilterApplied || swipeMap.isEmpty().not())
}
state.copy(
ownSwipes = swipes.associate { it.dateIdeaId to it.action },
dateIdeas = deck,
currentIndex = index,
swipeFilterApplied = applied,
ownSwipes = swipeMap,
matches = matches,
justMatched = newMatch ?: state.justMatched
)

View File

@ -124,7 +124,8 @@ class DesireSyncViewModel @Inject constructor(
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource,
private val premiumChecker: CouplePremiumChecker,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor,
private val seenQuestionStore: app.closer.data.local.SeenDesireQuestionStore
) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState())
@ -196,8 +197,19 @@ class DesireSyncViewModel @Inject constructor(
/** First partner: pick the neutral question set and open the shared session. */
private suspend fun createSession(uid: String) {
val questions = loadNeutralQuestions().shuffled().take(_uiState.value.selectedLength.count)
val count = _uiState.value.selectedLength.count
val pool = loadNeutralQuestions()
// Prefer prompts this couple hasn't seen so back-to-back rounds don't repeat. When the
// unseen remainder can't fill a round, wipe the history and cycle the whole pool afresh.
val cId = coupleId
val seen = if (cId != null) runCatching { seenQuestionStore.seen(cId) }.getOrDefault(emptySet()) else emptySet()
val unseen = pool.filterNot { it.id in seen }
val source = if (unseen.size >= count) unseen else pool.also {
if (cId != null) runCatching { seenQuestionStore.clear(cId) }
}
val questions = source.shuffled().take(count)
if (questions.isEmpty()) return fail("No questions available.")
if (cId != null) runCatching { seenQuestionStore.markSeen(cId, questions.map { it.id }) }
val startResult = runCatching {
gameSessionManager.startGame(
@ -481,7 +493,9 @@ fun DesireSyncScreen(
total = state.questions.size,
partnerName = state.partnerName,
onPlayAgain = viewModel::restart,
onHome = viewModel::quit
onHome = viewModel::quit,
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
)
}
@ -793,7 +807,9 @@ private fun DSRevealScreen(
total: Int,
partnerName: String,
onPlayAgain: () -> Unit,
onHome: () -> Unit
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
) {
LazyColumn(
modifier = Modifier
@ -881,6 +897,9 @@ private fun DSRevealScreen(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
if (showNextBeat) {
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}

View File

@ -0,0 +1,124 @@
package app.closer.ui.games
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.usecase.DailyQuestionResolver
import app.closer.ui.components.CloserGlyphs
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* The "what's next" beat shown under a game's results CTAs, so finishing a game doesn't dead-end.
* Priority: today's daily question if it's still unanswered (the return anchor), otherwise a
* gentle streak note. Self-contained its own ViewModel resolves the state so game screens only
* drop in the composable and provide navigation.
*/
sealed interface NextBeat {
data object None : NextBeat
data object DailyReady : NextBeat
data class Streak(val days: Int) : NextBeat
}
@HiltViewModel
class NextBeatViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val dailyQuestionResolver: DailyQuestionResolver,
private val localAnswerRepository: LocalAnswerRepository,
) : ViewModel() {
private val _beat = MutableStateFlow<NextBeat>(NextBeat.None)
val beat: StateFlow<NextBeat> = _beat.asStateFlow()
init {
viewModelScope.launch {
val resolved = runCatching { dailyQuestionResolver.resolve() }.getOrNull()
val todayQid = resolved?.question?.id
val answered = todayQid != null && runCatching {
localAnswerRepository.observeAnswers().first().any { it.questionId == todayQid }
}.getOrDefault(true) // fail safe: assume answered → don't nag on error
if (todayQid != null && !answered) {
_beat.value = NextBeat.DailyReady
return@launch
}
val streak = runCatching {
authRepository.currentUserId
?.let { coupleRepository.getCoupleForUser(it)?.streakCount }
}.getOrNull() ?: 0
if (streak > 0) _beat.value = NextBeat.Streak(streak)
}
}
}
@Composable
fun NextBeatCard(
onOpenDaily: () -> Unit,
modifier: Modifier = Modifier,
viewModel: NextBeatViewModel = hiltViewModel(),
) {
val beat by viewModel.beat.collectAsStateWithLifecycle()
when (val b = beat) {
is NextBeat.None -> Unit
is NextBeat.DailyReady -> Surface(
onClick = onOpenDaily,
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f),
modifier = modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Tonight's question is ready",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.weight(1f)
)
Icon(
CloserGlyphs.Forward,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
is NextBeat.Streak -> Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = modifier.fillMaxWidth()
) {
Text(
text = "Day ${b.days} together — see you tomorrow 💜",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)
)
}
}
}

View File

@ -448,6 +448,23 @@ class HowWellViewModel @Inject constructor(
load()
}
/**
* Newlywed-style role swap: the guesser starts a new round as the SUBJECT (starter == subject),
* so now their partner guesses them. Skips the length picker by reusing the last length.
*/
fun startSwapped() {
val len = _uiState.value.selectedLength
observeJob?.cancel()
sessionId = null
startedByUserId = null
submitted = false
myAnswers.clear()
_uiState.value = HowWellUiState(phase = HowWellPhase.LOADING, selectedLength = len)
val uid = userId
if (uid == null) { fail("You need to be signed in to play."); return }
viewModelScope.launch { createSession(uid) }
}
fun onNavigated() {
_uiState.update { it.copy(navigateTo = null) }
}
@ -532,7 +549,10 @@ fun HowWellScreen(
amSubject = state.amSubject,
partnerName = state.partnerName,
onPlayAgain = viewModel::restart,
onHome = viewModel::quit
onSwapRoles = viewModel::startSwapped,
onHome = viewModel::quit,
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
)
}
@ -877,7 +897,10 @@ private fun CompleteScreen(
amSubject: Boolean,
partnerName: String,
onPlayAgain: () -> Unit,
onHome: () -> Unit
onSwapRoles: () -> Unit,
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
) {
LazyColumn(
modifier = Modifier
@ -931,17 +954,36 @@ private fun CompleteScreen(
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
onClick = onPlayAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("Play again", color = Color.White) }
// The guesser just tried to read their partner — offer the natural next beat:
// swap so the partner now guesses THEM (Newlywed-style role reversal).
if (!amSubject) {
Button(
onClick = onSwapRoles,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("Your turn — let $partnerName guess you", color = Color.White) }
OutlinedButton(
onClick = onPlayAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Play again") }
} else {
Button(
onClick = onPlayAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("Play again", color = Color.White) }
}
OutlinedButton(
onClick = onHome,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
if (showNextBeat) {
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}
@ -1313,6 +1355,7 @@ fun HowWellReplayScreen(
amSubject = phase.amSubject,
partnerName = state.partnerName,
onPlayAgain = { onNavigate(AppRoute.HOW_WELL) },
onSwapRoles = { onNavigate(AppRoute.HOW_WELL) },
onHome = { onNavigate(AppRoute.PLAY) }
)
}

View File

@ -518,7 +518,9 @@ fun ThisOrThatScreen(
partnerName = state.partnerName,
cards = state.revealCards,
onPlayAgain = viewModel::restart,
onHome = viewModel::quit
onHome = viewModel::quit,
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
)
TotPhase.PLAYING -> {
val question = state.questions.getOrNull(state.currentIndex)
@ -1056,7 +1058,9 @@ private fun ThisOrThatReveal(
partnerName: String,
cards: List<RevealCard>,
onPlayAgain: () -> Unit,
onHome: () -> Unit
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
) {
val ratio = if (total > 0) matched.toFloat() / total else 0f
val headline = when {
@ -1123,6 +1127,10 @@ private fun ThisOrThatReveal(
) {
Text("Back to Play")
}
if (showNextBeat) {
Spacer(Modifier.height(14.dp))
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}

View File

@ -237,7 +237,9 @@ fun WheelCompleteScreen(
partnerName = state.partnerName,
items = state.items,
onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) },
onHome = { onNavigate(AppRoute.PLAY) }
onHome = { onNavigate(AppRoute.PLAY) },
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
)
WheelRevealPhase.ERROR -> WheelErrorContent(
message = state.error ?: "Something went wrong.",
@ -324,7 +326,9 @@ private fun WheelRevealContent(
partnerName: String,
items: List<WheelRevealItem>,
onSpinAgain: () -> Unit,
onHome: () -> Unit
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
) {
LazyColumn(
modifier = Modifier
@ -390,6 +394,10 @@ private fun WheelRevealContent(
) {
Text("Back to Play")
}
if (showNextBeat) {
Spacer(Modifier.height(14.dp))
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}