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:
parent
9d0a0c7fdf
commit
cf8f82357e
|
|
@ -58,7 +58,9 @@ interface QuestionDao {
|
||||||
@Query("SELECT * FROM question WHERE type = :type AND status = 'active' AND TRIM(text) <> ''")
|
@Query("SELECT * FROM question WHERE type = :type AND status = 'active' AND TRIM(text) <> ''")
|
||||||
suspend fun getQuestionsByType(type: String): List<QuestionEntity>
|
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>
|
suspend fun getQuestionsForPrediction(): List<QuestionEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM question WHERE sex = :sex AND status = 'active' AND TRIM(text) <> ''")
|
@Query("SELECT * FROM question WHERE sex = :sex AND status = 'active' AND TRIM(text) <> ''")
|
||||||
|
|
|
||||||
|
|
@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
data class DateMatchUiState(
|
data class DateMatchUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
|
|
@ -39,6 +40,8 @@ data class DateMatchUiState(
|
||||||
val ownSwipes: Map<String, SwipeAction> = emptyMap(),
|
val ownSwipes: Map<String, SwipeAction> = emptyMap(),
|
||||||
val matches: List<DateMatch> = emptyList(),
|
val matches: List<DateMatch> = emptyList(),
|
||||||
val currentIndex: Int = 0,
|
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 justMatched: DateMatch? = null,
|
||||||
val hasPremium: Boolean = false
|
val hasPremium: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
|
@ -87,7 +90,11 @@ class DateMatchViewModel @Inject constructor(
|
||||||
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
|
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
|
||||||
.getOrNull()
|
.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(
|
_uiState.value = DateMatchUiState(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
coupleId = couple?.id,
|
coupleId = couple?.id,
|
||||||
|
|
@ -124,9 +131,20 @@ class DateMatchViewModel @Inject constructor(
|
||||||
matches.firstOrNull { it.id !in known }
|
matches.firstOrNull { it.id !in known }
|
||||||
}
|
}
|
||||||
knownMatchIds = matches.mapTo(mutableSetOf()) { it.id }
|
knownMatchIds = matches.mapTo(mutableSetOf()) { it.id }
|
||||||
|
val swipeMap = swipes.associate { it.dateIdeaId to it.action }
|
||||||
_uiState.update { state ->
|
_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(
|
state.copy(
|
||||||
ownSwipes = swipes.associate { it.dateIdeaId to it.action },
|
dateIdeas = deck,
|
||||||
|
currentIndex = index,
|
||||||
|
swipeFilterApplied = applied,
|
||||||
|
ownSwipes = swipeMap,
|
||||||
matches = matches,
|
matches = matches,
|
||||||
justMatched = newMatch ?: state.justMatched
|
justMatched = newMatch ?: state.justMatched
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,8 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
private val gameSessionManager: GameSessionManager,
|
private val gameSessionManager: GameSessionManager,
|
||||||
private val dataSource: FirestoreDesireSyncDataSource,
|
private val dataSource: FirestoreDesireSyncDataSource,
|
||||||
private val premiumChecker: CouplePremiumChecker,
|
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() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(DesireSyncUiState())
|
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. */
|
/** First partner: pick the neutral question set and open the shared session. */
|
||||||
private suspend fun createSession(uid: String) {
|
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 (questions.isEmpty()) return fail("No questions available.")
|
||||||
|
if (cId != null) runCatching { seenQuestionStore.markSeen(cId, questions.map { it.id }) }
|
||||||
|
|
||||||
val startResult = runCatching {
|
val startResult = runCatching {
|
||||||
gameSessionManager.startGame(
|
gameSessionManager.startGame(
|
||||||
|
|
@ -481,7 +493,9 @@ fun DesireSyncScreen(
|
||||||
total = state.questions.size,
|
total = state.questions.size,
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
onPlayAgain = viewModel::restart,
|
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,
|
total: Int,
|
||||||
partnerName: String,
|
partnerName: String,
|
||||||
onPlayAgain: () -> Unit,
|
onPlayAgain: () -> Unit,
|
||||||
onHome: () -> Unit
|
onHome: () -> Unit,
|
||||||
|
onOpenDaily: () -> Unit = {},
|
||||||
|
showNextBeat: Boolean = false
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -881,6 +897,9 @@ private fun DSRevealScreen(
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
shape = RoundedCornerShape(18.dp)
|
shape = RoundedCornerShape(18.dp)
|
||||||
) { Text("Back to Play") }
|
) { Text("Back to Play") }
|
||||||
|
if (showNextBeat) {
|
||||||
|
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -448,6 +448,23 @@ class HowWellViewModel @Inject constructor(
|
||||||
load()
|
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() {
|
fun onNavigated() {
|
||||||
_uiState.update { it.copy(navigateTo = null) }
|
_uiState.update { it.copy(navigateTo = null) }
|
||||||
}
|
}
|
||||||
|
|
@ -532,7 +549,10 @@ fun HowWellScreen(
|
||||||
amSubject = state.amSubject,
|
amSubject = state.amSubject,
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
onPlayAgain = viewModel::restart,
|
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,
|
amSubject: Boolean,
|
||||||
partnerName: String,
|
partnerName: String,
|
||||||
onPlayAgain: () -> Unit,
|
onPlayAgain: () -> Unit,
|
||||||
onHome: () -> Unit
|
onSwapRoles: () -> Unit,
|
||||||
|
onHome: () -> Unit,
|
||||||
|
onOpenDaily: () -> Unit = {},
|
||||||
|
showNextBeat: Boolean = false
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -931,17 +954,36 @@ private fun CompleteScreen(
|
||||||
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
|
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
|
// 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(
|
Button(
|
||||||
onClick = onPlayAgain,
|
onClick = onPlayAgain,
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
||||||
) { Text("Play again", color = Color.White) }
|
) { Text("Play again", color = Color.White) }
|
||||||
|
}
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onHome,
|
onClick = onHome,
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||||
shape = RoundedCornerShape(18.dp)
|
shape = RoundedCornerShape(18.dp)
|
||||||
) { Text("Back to Play") }
|
) { Text("Back to Play") }
|
||||||
|
if (showNextBeat) {
|
||||||
|
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1313,6 +1355,7 @@ fun HowWellReplayScreen(
|
||||||
amSubject = phase.amSubject,
|
amSubject = phase.amSubject,
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
onPlayAgain = { onNavigate(AppRoute.HOW_WELL) },
|
onPlayAgain = { onNavigate(AppRoute.HOW_WELL) },
|
||||||
|
onSwapRoles = { onNavigate(AppRoute.HOW_WELL) },
|
||||||
onHome = { onNavigate(AppRoute.PLAY) }
|
onHome = { onNavigate(AppRoute.PLAY) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -518,7 +518,9 @@ fun ThisOrThatScreen(
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
cards = state.revealCards,
|
cards = state.revealCards,
|
||||||
onPlayAgain = viewModel::restart,
|
onPlayAgain = viewModel::restart,
|
||||||
onHome = viewModel::quit
|
onHome = viewModel::quit,
|
||||||
|
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
|
||||||
|
showNextBeat = true
|
||||||
)
|
)
|
||||||
TotPhase.PLAYING -> {
|
TotPhase.PLAYING -> {
|
||||||
val question = state.questions.getOrNull(state.currentIndex)
|
val question = state.questions.getOrNull(state.currentIndex)
|
||||||
|
|
@ -1056,7 +1058,9 @@ private fun ThisOrThatReveal(
|
||||||
partnerName: String,
|
partnerName: String,
|
||||||
cards: List<RevealCard>,
|
cards: List<RevealCard>,
|
||||||
onPlayAgain: () -> Unit,
|
onPlayAgain: () -> Unit,
|
||||||
onHome: () -> Unit
|
onHome: () -> Unit,
|
||||||
|
onOpenDaily: () -> Unit = {},
|
||||||
|
showNextBeat: Boolean = false
|
||||||
) {
|
) {
|
||||||
val ratio = if (total > 0) matched.toFloat() / total else 0f
|
val ratio = if (total > 0) matched.toFloat() / total else 0f
|
||||||
val headline = when {
|
val headline = when {
|
||||||
|
|
@ -1123,6 +1127,10 @@ private fun ThisOrThatReveal(
|
||||||
) {
|
) {
|
||||||
Text("Back to Play")
|
Text("Back to Play")
|
||||||
}
|
}
|
||||||
|
if (showNextBeat) {
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,9 @@ fun WheelCompleteScreen(
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
items = state.items,
|
items = state.items,
|
||||||
onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) },
|
onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) },
|
||||||
onHome = { onNavigate(AppRoute.PLAY) }
|
onHome = { onNavigate(AppRoute.PLAY) },
|
||||||
|
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
|
||||||
|
showNextBeat = true
|
||||||
)
|
)
|
||||||
WheelRevealPhase.ERROR -> WheelErrorContent(
|
WheelRevealPhase.ERROR -> WheelErrorContent(
|
||||||
message = state.error ?: "Something went wrong.",
|
message = state.error ?: "Something went wrong.",
|
||||||
|
|
@ -324,7 +326,9 @@ private fun WheelRevealContent(
|
||||||
partnerName: String,
|
partnerName: String,
|
||||||
items: List<WheelRevealItem>,
|
items: List<WheelRevealItem>,
|
||||||
onSpinAgain: () -> Unit,
|
onSpinAgain: () -> Unit,
|
||||||
onHome: () -> Unit
|
onHome: () -> Unit,
|
||||||
|
onOpenDaily: () -> Unit = {},
|
||||||
|
showNextBeat: Boolean = false
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -390,6 +394,10 @@ private fun WheelRevealContent(
|
||||||
) {
|
) {
|
||||||
Text("Back to Play")
|
Text("Back to Play")
|
||||||
}
|
}
|
||||||
|
if (showNextBeat) {
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue