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) <> ''")
|
||||
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) <> ''")
|
||||
|
|
|
|||
|
|
@ -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.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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
) {
|
||||
// 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) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue