diff --git a/app/src/main/java/app/closer/data/local/QuestionDao.kt b/app/src/main/java/app/closer/data/local/QuestionDao.kt index 76bc36fb..92ec90ae 100644 --- a/app/src/main/java/app/closer/data/local/QuestionDao.kt +++ b/app/src/main/java/app/closer/data/local/QuestionDao.kt @@ -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 - @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 @Query("SELECT * FROM question WHERE sex = :sex AND status = 'active' AND TRIM(text) <> ''") diff --git a/app/src/main/java/app/closer/data/local/SeenDesireQuestionStore.kt b/app/src/main/java/app/closer/data/local/SeenDesireQuestionStore.kt new file mode 100644 index 00000000..61e06d3f --- /dev/null +++ b/app/src/main/java/app/closer/data/local/SeenDesireQuestionStore.kt @@ -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 +) { + private fun keyFor(coupleId: String) = stringSetPreferencesKey("seen_desire_$coupleId") + + suspend fun seen(coupleId: String): Set = + dataStore.data.map { it[keyFor(coupleId)] ?: emptySet() }.first() + + suspend fun markSeen(coupleId: String, ids: Collection) { + dataStore.edit { prefs -> + prefs[keyFor(coupleId)] = (prefs[keyFor(coupleId)] ?: emptySet()) + ids + } + } + + suspend fun clear(coupleId: String) { + dataStore.edit { it.remove(keyFor(coupleId)) } + } +} diff --git a/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt b/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt index 17ebb976..76f5183a 100644 --- a/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt +++ b/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt @@ -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 = emptyMap(), val matches: List = 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 ) diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index a1f1b00c..95afb438 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -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) + } } } } diff --git a/app/src/main/java/app/closer/ui/games/NextBeatCard.kt b/app/src/main/java/app/closer/ui/games/NextBeatCard.kt new file mode 100644 index 00000000..d9b41be3 --- /dev/null +++ b/app/src/main/java/app/closer/ui/games/NextBeatCard.kt @@ -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.None) + val beat: StateFlow = _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) + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index 560e2170..db06c3ef 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -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) } ) } diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 0f70eceb..185efd86 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -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, 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) + } } } } diff --git a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt index b7ca1579..431ec3e4 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt @@ -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, 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) + } } } }