feat: nav routes, play hub, spin wheel screen + viewmodel, firestore rules

This commit is contained in:
null 2026-06-22 10:53:05 -05:00
parent ecc41a77d2
commit 7d5fc11366
6 changed files with 280 additions and 100 deletions

View File

@ -335,13 +335,21 @@ fun AppNavigation(
composable(route = AppRoute.CATEGORY_PICKER) { composable(route = AppRoute.CATEGORY_PICKER) {
CategoryPickerScreen(onNavigate = navigateRoute) CategoryPickerScreen(onNavigate = navigateRoute)
} }
composable(route = AppRoute.SPIN_WHEEL_RANDOM) {
SpinWheelScreen(
categoryId = "",
onNavigate = navigateRoute,
onBack = navigateBackOrHome
)
}
composable( composable(
route = AppRoute.SPIN_WHEEL, route = AppRoute.SPIN_WHEEL,
arguments = listOf(navArgument("categoryId") { type = NavType.StringType }) arguments = listOf(navArgument("categoryId") { type = NavType.StringType })
) { ) {
SpinWheelScreen( SpinWheelScreen(
categoryId = it.arguments?.getString("categoryId") ?: "", categoryId = it.arguments?.getString("categoryId") ?: "",
onNavigate = navigateRoute onNavigate = navigateRoute,
onBack = navigateBackOrHome
) )
} }
composable( composable(

View File

@ -23,6 +23,7 @@ object AppRoute {
const val INVITE_CONFIRM = "invite_confirm/{inviteCode}" const val INVITE_CONFIRM = "invite_confirm/{inviteCode}"
const val CATEGORY_PICKER = "category_picker" const val CATEGORY_PICKER = "category_picker"
const val SPIN_WHEEL = "spin_wheel/{categoryId}" const val SPIN_WHEEL = "spin_wheel/{categoryId}"
const val SPIN_WHEEL_RANDOM = "spin_wheel_random"
const val WHEEL_SESSION = "wheel_session/{sessionId}" const val WHEEL_SESSION = "wheel_session/{sessionId}"
const val WHEEL_COMPLETE = "wheel_complete/{sessionId}" const val WHEEL_COMPLETE = "wheel_complete/{sessionId}"
const val PAYWALL = "paywall" const val PAYWALL = "paywall"
@ -89,6 +90,7 @@ object AppRoute {
Definition(INVITE_CONFIRM, "Invite Confirm", "pairing"), Definition(INVITE_CONFIRM, "Invite Confirm", "pairing"),
Definition(CATEGORY_PICKER, "Choose A Category", "wheel"), Definition(CATEGORY_PICKER, "Choose A Category", "wheel"),
Definition(SPIN_WHEEL, "Spin", "wheel"), Definition(SPIN_WHEEL, "Spin", "wheel"),
Definition(SPIN_WHEEL_RANDOM, "Spin the Wheel", "wheel"),
Definition(WHEEL_SESSION, "Wheel Session", "wheel"), Definition(WHEEL_SESSION, "Wheel Session", "wheel"),
Definition(WHEEL_COMPLETE, "Complete", "wheel"), Definition(WHEEL_COMPLETE, "Complete", "wheel"),
Definition(PAYWALL, "Unlock Everything", "paywall"), Definition(PAYWALL, "Unlock Everything", "paywall"),
@ -152,6 +154,7 @@ object AppRoute {
ANSWER_REVEAL, ANSWER_REVEAL,
CATEGORY_PICKER, CATEGORY_PICKER,
SPIN_WHEEL, SPIN_WHEEL,
SPIN_WHEEL_RANDOM,
WHEEL_SESSION, WHEEL_SESSION,
WHEEL_COMPLETE, WHEEL_COMPLETE,
WHEEL_HISTORY, WHEEL_HISTORY,

View File

@ -98,7 +98,7 @@ private fun PlayHubContent(
item { item {
FeaturedPlayCard( FeaturedPlayCard(
onClick = { onNavigate(AppRoute.CATEGORY_PICKER) } onClick = { onNavigate(AppRoute.SPIN_WHEEL_RANDOM) }
) )
} }
@ -598,7 +598,7 @@ private fun FeaturedPlayCard(
} }
CloserActionButton( CloserActionButton(
label = "Choose category", label = "Play",
onClick = onClick, onClick = onClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@ -1,7 +1,7 @@
package app.closer.ui.wheel package app.closer.ui.wheel
import app.closer.ui.theme.closerCardColor import app.closer.ui.theme.closerCardColor
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@ -15,6 +15,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
@ -22,21 +24,29 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.foundation.layout.Row import androidx.compose.material3.TextButton
import app.closer.domain.model.SessionLength
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
@ -60,6 +70,7 @@ import app.closer.ui.theme.closerBackgroundBrush
fun SpinWheelScreen( fun SpinWheelScreen(
categoryId: String, categoryId: String,
onNavigate: (String) -> Unit = {}, onNavigate: (String) -> Unit = {},
onBack: () -> Unit = {},
viewModel: SpinWheelViewModel = hiltViewModel() viewModel: SpinWheelViewModel = hiltViewModel()
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
@ -75,7 +86,10 @@ fun SpinWheelScreen(
state = state, state = state,
onSpin = viewModel::spin, onSpin = viewModel::spin,
onStart = viewModel::startSession, onStart = viewModel::startSession,
onLengthSelected = viewModel::setLength onChooseCategory = viewModel::onChooseCategory,
onHistory = viewModel::onHistory,
onPaywall = viewModel::onPaywall,
onBack = onBack
) )
} }
@ -84,19 +98,11 @@ private fun SpinWheelContent(
state: SpinWheelUiState, state: SpinWheelUiState,
onSpin: () -> Unit, onSpin: () -> Unit,
onStart: () -> Unit, onStart: () -> Unit,
onLengthSelected: (SessionLength) -> Unit = {} onChooseCategory: () -> Unit,
onHistory: () -> Unit,
onPaywall: () -> Unit,
onBack: () -> Unit
) { ) {
val infiniteTransition = rememberInfiniteTransition(label = "wheel")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "wheel_rotation"
)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -112,7 +118,31 @@ private fun SpinWheelContent(
verticalArrangement = Arrangement.spacedBy(28.dp), verticalArrangement = Arrangement.spacedBy(28.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Header row with back button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onBackground
)
}
TextButton(onClick = onHistory) {
Text(
"History",
style = MaterialTheme.typography.labelLarge,
color = CloserPalette.PurpleDeep
)
}
}
// Title + category pill
Column( Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
@ -148,20 +178,14 @@ private fun SpinWheelContent(
WheelSpinner( WheelSpinner(
isSpinning = state.isSpinning, isSpinning = state.isSpinning,
spunAndReady = state.spunAndReady, spunAndReady = state.spunAndReady,
rotation = rotation onSpin = onSpin
) )
} }
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
if (!state.isLoading && !state.isSpinning) {
WheelLengthChips(
selected = state.selectedLength,
onSelect = onLengthSelected
)
}
when { when {
state.error != null -> Text( state.error != null -> Text(
text = state.error, text = state.error,
@ -172,14 +196,24 @@ private fun SpinWheelContent(
) )
state.spunAndReady -> { state.spunAndReady -> {
Text( Text(
text = "${state.selectedLength.count} questions selected", text = "10 questions ready · ${state.categoryName}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
if (!state.isPaired) {
Text(
text = "Connect with a partner to start playing",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Button( Button(
onClick = onStart, onClick = onStart,
enabled = state.isPaired,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 56.dp), .heightIn(min = 56.dp),
@ -188,24 +222,50 @@ private fun SpinWheelContent(
) { ) {
Text("Start session", color = MaterialTheme.colorScheme.surface) Text("Start session", color = MaterialTheme.colorScheme.surface)
} }
// Spin again is a premium feature
OutlinedButton( OutlinedButton(
onClick = onSpin, onClick = { if (state.hasPremium) onSpin() else onPaywall() },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 56.dp), .heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
border = BorderStroke(1.dp, CloserPalette.PurpleDeep.copy(alpha = 0.44f)), border = BorderStroke(
1.dp,
CloserPalette.PurpleDeep.copy(alpha = if (state.hasPremium) 0.44f else 0.28f)
),
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.outlinedButtonColors(
contentColor = CloserPalette.PurpleDeep contentColor = CloserPalette.PurpleDeep
) )
) { ) {
if (!state.hasPremium) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(6.dp))
}
Text("Spin again") Text("Spin again")
if (!state.hasPremium) {
Spacer(Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(999.dp),
color = CloserPalette.PurpleMist
) {
Text(
text = "Premium",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = CloserPalette.PurpleDeep
)
}
}
} }
} }
state.isLoading -> CircularProgressIndicator(color = CloserPalette.PurpleDeep) state.isLoading -> CircularProgressIndicator(color = CloserPalette.PurpleDeep)
else -> { else -> {
Text( Text(
text = "Tap to select ${state.selectedLength.count} questions at random", text = "Spin to discover a random category and 10 questions",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@ -221,6 +281,13 @@ private fun SpinWheelContent(
) { ) {
Text("Spin wheel", color = MaterialTheme.colorScheme.surface) Text("Spin wheel", color = MaterialTheme.colorScheme.surface)
} }
// Premium: choose a specific category
if (!state.isSpinning) {
ChooseCategoryButton(
hasPremium = state.hasPremium,
onClick = onChooseCategory
)
}
} }
} }
} }
@ -228,12 +295,72 @@ private fun SpinWheelContent(
} }
} }
@Composable
private fun ChooseCategoryButton(hasPremium: Boolean, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
shape = RoundedCornerShape(18.dp),
border = BorderStroke(1.dp, CloserPalette.PurpleDeep.copy(alpha = 0.28f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = CloserPalette.PurpleDeep)
) {
if (!hasPremium) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(6.dp))
}
Text(
text = "Choose a category",
style = MaterialTheme.typography.labelLarge
)
if (!hasPremium) {
Spacer(Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(999.dp),
color = CloserPalette.PurpleMist
) {
Text(
text = "Premium",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = CloserPalette.PurpleDeep
)
}
}
}
}
@Composable @Composable
private fun WheelSpinner( private fun WheelSpinner(
isSpinning: Boolean, isSpinning: Boolean,
spunAndReady: Boolean, spunAndReady: Boolean,
rotation: Float onSpin: () -> Unit
) { ) {
// Accumulated spin angle — increases with each spin, never resets.
// Using a box so LaunchedEffect can write into it without triggering recomposition of its parent.
var spinEndAngle by remember { mutableFloatStateOf(0f) }
LaunchedEffect(isSpinning) {
if (isSpinning) {
// 4-6 full rotations plus a random stop angle for variety
spinEndAngle += 360f * (4..6).random() + (0..359).random()
}
}
val wheelAngle by animateFloatAsState(
targetValue = spinEndAngle,
animationSpec = tween(
durationMillis = SpinWheelViewModel.SPIN_DURATION_MS.toInt(),
easing = LinearOutSlowInEasing
),
label = "wheel_decel"
)
val idleTransition = rememberInfiniteTransition(label = "wheel_idle") val idleTransition = rememberInfiniteTransition(label = "wheel_idle")
val idlePulse by idleTransition.animateFloat( val idlePulse by idleTransition.animateFloat(
initialValue = 0.98f, initialValue = 0.98f,
@ -268,12 +395,10 @@ private fun WheelSpinner(
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
.size(236.dp) .size(236.dp)
.rotate(if (isSpinning) rotation else 0f) .rotate(wheelAngle)
) )
Canvas( Canvas(modifier = Modifier.size(236.dp)) {
modifier = Modifier.size(236.dp)
) {
drawCircle( drawCircle(
color = wheelRingColor, color = wheelRingColor,
style = Stroke(width = 7.dp.toPx()) style = Stroke(width = 7.dp.toPx())
@ -296,6 +421,8 @@ private fun WheelSpinner(
} }
Surface( Surface(
onClick = onSpin,
enabled = !isSpinning && !spunAndReady,
modifier = Modifier modifier = Modifier
.size(94.dp) .size(94.dp)
.scale(centerScale), .scale(centerScale),
@ -317,43 +444,16 @@ private fun WheelSpinner(
} }
} }
@Composable
private fun WheelLengthChips(
selected: SessionLength,
onSelect: (SessionLength) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SessionLength.values().forEach { len ->
Surface(
onClick = { onSelect(len) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
color = if (len == selected) CloserPalette.PurpleDeep else Color.Transparent,
border = if (len != selected)
BorderStroke(1.dp, CloserPalette.PurpleDeep.copy(alpha = 0.4f))
else null
) {
Text(
text = len.label,
modifier = Modifier.padding(vertical = 10.dp),
style = MaterialTheme.typography.labelMedium,
color = if (len == selected) Color.White else CloserPalette.PurpleDeep,
textAlign = TextAlign.Center
)
}
}
}
}
@Preview @Preview
@Composable @Composable
fun SpinWheelScreenPreview() { fun SpinWheelScreenPreview() {
SpinWheelContent( SpinWheelContent(
state = SpinWheelUiState(isLoading = false, categoryName = "Trust"), state = SpinWheelUiState(isLoading = false, categoryName = "Trust"),
onSpin = {}, onSpin = {},
onStart = {} onStart = {},
onChooseCategory = {},
onHistory = {},
onPaywall = {},
onBack = {}
) )
} }

View File

@ -4,25 +4,30 @@ import android.util.Log
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType import app.closer.domain.model.GameType
import app.closer.domain.model.SessionLength import app.closer.domain.model.QuestionCategory
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class SpinWheelUiState( data class SpinWheelUiState(
val isLoading: Boolean = true, val isLoading: Boolean = false,
val categoryName: String = "", val categoryName: String = "",
val isSpinning: Boolean = false, val isSpinning: Boolean = false,
val spunAndReady: Boolean = false, val spunAndReady: Boolean = false,
val selectedLength: SessionLength = SessionLength.STANDARD, val hasPremium: Boolean = false,
val isPaired: Boolean = true,
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null
) )
@ -32,16 +37,37 @@ class SpinWheelViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val sessionStore: LocalWheelSessionStore, private val sessionStore: LocalWheelSessionStore,
private val gameSessionManager: GameSessionManager private val gameSessionManager: GameSessionManager,
private val entitlementChecker: EntitlementChecker
) : ViewModel() { ) : ViewModel() {
private val categoryId: String = savedStateHandle["categoryId"] ?: "" private val categoryId: String = savedStateHandle["categoryId"] ?: ""
private val isRandomMode: Boolean get() = categoryId.isEmpty()
private val _uiState = MutableStateFlow(SpinWheelUiState()) private val _uiState = MutableStateFlow(SpinWheelUiState(isLoading = categoryId.isNotEmpty()))
val uiState: StateFlow<SpinWheelUiState> = _uiState.asStateFlow() val uiState: StateFlow<SpinWheelUiState> = _uiState.asStateFlow()
init { init {
loadCategory() if (!isRandomMode) loadCategory()
loadPremiumStatus()
loadPairedStatus()
checkActiveSession()
}
private fun loadPremiumStatus() {
viewModelScope.launch {
val hasPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false)
_uiState.update { it.copy(hasPremium = hasPremium) }
}
}
private fun loadPairedStatus() {
viewModelScope.launch {
val uid = gameSessionManager.currentUserId
val paired = uid != null &&
runCatching { gameSessionManager.getCoupleForUser(uid) }.getOrNull() != null
_uiState.update { it.copy(isPaired = paired) }
}
} }
private fun loadCategory() { private fun loadCategory() {
@ -50,43 +76,77 @@ class SpinWheelViewModel @Inject constructor(
.onFailure { Log.w(TAG, "Could not load wheel category", it) } .onFailure { Log.w(TAG, "Could not load wheel category", it) }
.getOrNull() .getOrNull()
_uiState.update { _uiState.update {
it.copy( it.copy(isLoading = false, categoryName = category?.displayName ?: categoryId)
isLoading = false,
categoryName = category?.displayName ?: categoryId
)
} }
} }
} }
private fun checkActiveSession() {
viewModelScope.launch {
val uid = gameSessionManager.currentUserId ?: return@launch
val couple = runCatching { gameSessionManager.getCoupleForUser(uid) }.getOrNull() ?: return@launch
val active = runCatching { gameSessionManager.getActiveSession(couple.id) }.getOrNull() ?: return@launch
val target = if (active.gameType == GameType.WHEEL) {
AppRoute.wheelSession(active.id)
} else {
AppRoute.WAITING_FOR_PARTNER
}
_uiState.update { it.copy(navigateTo = target) }
}
}
fun spin() { fun spin() {
if (_uiState.value.isSpinning) return if (_uiState.value.isSpinning) return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isSpinning = true, error = null) } _uiState.update { it.copy(isSpinning = true, spunAndReady = false, error = null) }
val questions = runCatching {
repository.getQuestionsByCategory(categoryId).shuffled().take(_uiState.value.selectedLength.count)
}
.onFailure { Log.w(TAG, "Could not load wheel questions", it) }
.getOrElse { emptyList() }
if (questions.isEmpty()) { val loadDeferred = async {
runCatching { loadSpinData() }.getOrNull()
}
// Hold the spinning state for the full animation duration regardless of load speed
delay(SPIN_DURATION_MS)
val result = loadDeferred.await()
if (result == null || result.second.isEmpty()) {
_uiState.update { _uiState.update {
it.copy(isSpinning = false, error = "No questions found for this category.") it.copy(isSpinning = false, error = "No questions found. Try another spin.")
} }
return@launch return@launch
} }
val category = runCatching { repository.getCategoryById(categoryId) } val (category, questions) = result
.onFailure { Log.w(TAG, "Could not load wheel category for session", it) }
.getOrNull()
sessionStore.activeSession = LocalWheelSession( sessionStore.activeSession = LocalWheelSession(
categoryId = categoryId, categoryId = category.id,
categoryName = category?.displayName ?: categoryId, categoryName = category.displayName,
questions = questions questions = questions
) )
_uiState.update { it.copy(isSpinning = false, spunAndReady = true) } _uiState.update {
it.copy(
isSpinning = false,
spunAndReady = true,
categoryName = category.displayName
)
}
} }
} }
private suspend fun loadSpinData(): Pair<QuestionCategory, List<app.closer.domain.model.Question>> {
val effectiveCategoryId = if (isRandomMode) {
val categories = repository.getCategories()
if (categories.isEmpty()) error("No categories available")
categories.random().id
} else {
categoryId
}
val category = repository.getCategoryById(effectiveCategoryId)
?: QuestionCategory(effectiveCategoryId, effectiveCategoryId, "", "free", "")
val questions = repository.getQuestionsByCategory(effectiveCategoryId)
.shuffled()
.take(QUESTION_COUNT)
return category to questions
}
fun startSession() { fun startSession() {
viewModelScope.launch { viewModelScope.launch {
val userId = gameSessionManager.currentUserId val userId = gameSessionManager.currentUserId
@ -109,18 +169,17 @@ class SpinWheelViewModel @Inject constructor(
}.getOrNull() ?: false }.getOrNull() ?: false
if (hasActive) { if (hasActive) {
_uiState.update { _uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) }
it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER)
}
return@launch return@launch
} }
val effectiveCategoryId = sessionStore.activeSession?.categoryId ?: categoryId
val questionIds = sessionStore.activeSession?.questions?.map { it.id } val questionIds = sessionStore.activeSession?.questions?.map { it.id }
val startResult = runCatching { val startResult = runCatching {
gameSessionManager.startGame( gameSessionManager.startGame(
userId = userId, userId = userId,
gameType = GameType.WHEEL, gameType = GameType.WHEEL,
categoryId = categoryId, categoryId = effectiveCategoryId,
questionIds = questionIds questionIds = questionIds
) )
}.getOrElse { Result.failure(it) } }.getOrElse { Result.failure(it) }
@ -141,9 +200,17 @@ class SpinWheelViewModel @Inject constructor(
} }
} }
fun setLength(len: SessionLength) { fun onChooseCategory() {
// Resets spunAndReady so the user re-spins with the new count. val target = if (_uiState.value.hasPremium) AppRoute.CATEGORY_PICKER else AppRoute.PAYWALL
_uiState.update { it.copy(selectedLength = len, spunAndReady = false) } _uiState.update { it.copy(navigateTo = target) }
}
fun onPaywall() {
_uiState.update { it.copy(navigateTo = AppRoute.PAYWALL) }
}
fun onHistory() {
_uiState.update { it.copy(navigateTo = AppRoute.WHEEL_HISTORY) }
} }
fun onNavigated() { fun onNavigated() {
@ -152,5 +219,7 @@ class SpinWheelViewModel @Inject constructor(
companion object { companion object {
private const val TAG = "SpinWheelViewModel" private const val TAG = "SpinWheelViewModel"
const val SPIN_DURATION_MS = 3500L
private const val QUESTION_COUNT = 10
} }
} }

View File

@ -231,7 +231,7 @@ service cloud.firestore {
== get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId); == get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId);
allow create, update: if isOwner(uid) allow create, update: if isOwner(uid)
&& request.resource.data.publicKey is string && request.resource.data.publicKey is string
&& request.resource.data.publicKey.matches('^pub:v1:') && request.resource.data.publicKey.matches('^pub:v1:.*')
&& request.resource.data.keys().hasOnly(['deviceId', 'publicKey', 'platform', 'updatedAt']); && request.resource.data.keys().hasOnly(['deviceId', 'publicKey', 'platform', 'updatedAt']);
allow delete: if false; allow delete: if false;
} }