feat: nav routes, play hub, spin wheel screen + viewmodel, firestore rules
This commit is contained in:
parent
4dcafa688e
commit
5ac4f40bf6
|
|
@ -335,13 +335,21 @@ fun AppNavigation(
|
|||
composable(route = AppRoute.CATEGORY_PICKER) {
|
||||
CategoryPickerScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(route = AppRoute.SPIN_WHEEL_RANDOM) {
|
||||
SpinWheelScreen(
|
||||
categoryId = "",
|
||||
onNavigate = navigateRoute,
|
||||
onBack = navigateBackOrHome
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.SPIN_WHEEL,
|
||||
arguments = listOf(navArgument("categoryId") { type = NavType.StringType })
|
||||
) {
|
||||
SpinWheelScreen(
|
||||
categoryId = it.arguments?.getString("categoryId") ?: "",
|
||||
onNavigate = navigateRoute
|
||||
onNavigate = navigateRoute,
|
||||
onBack = navigateBackOrHome
|
||||
)
|
||||
}
|
||||
composable(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ object AppRoute {
|
|||
const val INVITE_CONFIRM = "invite_confirm/{inviteCode}"
|
||||
const val CATEGORY_PICKER = "category_picker"
|
||||
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_COMPLETE = "wheel_complete/{sessionId}"
|
||||
const val PAYWALL = "paywall"
|
||||
|
|
@ -89,6 +90,7 @@ object AppRoute {
|
|||
Definition(INVITE_CONFIRM, "Invite Confirm", "pairing"),
|
||||
Definition(CATEGORY_PICKER, "Choose A Category", "wheel"),
|
||||
Definition(SPIN_WHEEL, "Spin", "wheel"),
|
||||
Definition(SPIN_WHEEL_RANDOM, "Spin the Wheel", "wheel"),
|
||||
Definition(WHEEL_SESSION, "Wheel Session", "wheel"),
|
||||
Definition(WHEEL_COMPLETE, "Complete", "wheel"),
|
||||
Definition(PAYWALL, "Unlock Everything", "paywall"),
|
||||
|
|
@ -152,6 +154,7 @@ object AppRoute {
|
|||
ANSWER_REVEAL,
|
||||
CATEGORY_PICKER,
|
||||
SPIN_WHEEL,
|
||||
SPIN_WHEEL_RANDOM,
|
||||
WHEEL_SESSION,
|
||||
WHEEL_COMPLETE,
|
||||
WHEEL_HISTORY,
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ private fun PlayHubContent(
|
|||
|
||||
item {
|
||||
FeaturedPlayCard(
|
||||
onClick = { onNavigate(AppRoute.CATEGORY_PICKER) }
|
||||
onClick = { onNavigate(AppRoute.SPIN_WHEEL_RANDOM) }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -598,7 +598,7 @@ private fun FeaturedPlayCard(
|
|||
}
|
||||
|
||||
CloserActionButton(
|
||||
label = "Choose category",
|
||||
label = "Play",
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package app.closer.ui.wheel
|
||||
|
||||
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.animateFloat
|
||||
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.Box
|
||||
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.fillMaxWidth
|
||||
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.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import app.closer.domain.model.SessionLength
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
|
|
@ -60,6 +70,7 @@ import app.closer.ui.theme.closerBackgroundBrush
|
|||
fun SpinWheelScreen(
|
||||
categoryId: String,
|
||||
onNavigate: (String) -> Unit = {},
|
||||
onBack: () -> Unit = {},
|
||||
viewModel: SpinWheelViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
|
@ -75,7 +86,10 @@ fun SpinWheelScreen(
|
|||
state = state,
|
||||
onSpin = viewModel::spin,
|
||||
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,
|
||||
onSpin: () -> 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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -112,7 +118,31 @@ private fun SpinWheelContent(
|
|||
verticalArrangement = Arrangement.spacedBy(28.dp),
|
||||
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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
|
|
@ -148,20 +178,14 @@ private fun SpinWheelContent(
|
|||
WheelSpinner(
|
||||
isSpinning = state.isSpinning,
|
||||
spunAndReady = state.spunAndReady,
|
||||
rotation = rotation
|
||||
onSpin = onSpin
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
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 {
|
||||
state.error != null -> Text(
|
||||
text = state.error,
|
||||
|
|
@ -172,14 +196,24 @@ private fun SpinWheelContent(
|
|||
)
|
||||
state.spunAndReady -> {
|
||||
Text(
|
||||
text = "${state.selectedLength.count} questions selected",
|
||||
text = "10 questions ready · ${state.categoryName}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
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(
|
||||
onClick = onStart,
|
||||
enabled = state.isPaired,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp),
|
||||
|
|
@ -188,24 +222,50 @@ private fun SpinWheelContent(
|
|||
) {
|
||||
Text("Start session", color = MaterialTheme.colorScheme.surface)
|
||||
}
|
||||
// Spin again is a premium feature
|
||||
OutlinedButton(
|
||||
onClick = onSpin,
|
||||
onClick = { if (state.hasPremium) onSpin() else onPaywall() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.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(
|
||||
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")
|
||||
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)
|
||||
else -> {
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
|
|
@ -221,6 +281,13 @@ private fun SpinWheelContent(
|
|||
) {
|
||||
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
|
||||
private fun WheelSpinner(
|
||||
isSpinning: 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 idlePulse by idleTransition.animateFloat(
|
||||
initialValue = 0.98f,
|
||||
|
|
@ -268,12 +395,10 @@ private fun WheelSpinner(
|
|||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.size(236.dp)
|
||||
.rotate(if (isSpinning) rotation else 0f)
|
||||
.rotate(wheelAngle)
|
||||
)
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier.size(236.dp)
|
||||
) {
|
||||
Canvas(modifier = Modifier.size(236.dp)) {
|
||||
drawCircle(
|
||||
color = wheelRingColor,
|
||||
style = Stroke(width = 7.dp.toPx())
|
||||
|
|
@ -296,6 +421,8 @@ private fun WheelSpinner(
|
|||
}
|
||||
|
||||
Surface(
|
||||
onClick = onSpin,
|
||||
enabled = !isSpinning && !spunAndReady,
|
||||
modifier = Modifier
|
||||
.size(94.dp)
|
||||
.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
|
||||
@Composable
|
||||
fun SpinWheelScreenPreview() {
|
||||
SpinWheelContent(
|
||||
state = SpinWheelUiState(isLoading = false, categoryName = "Trust"),
|
||||
onSpin = {},
|
||||
onStart = {}
|
||||
onStart = {},
|
||||
onChooseCategory = {},
|
||||
onHistory = {},
|
||||
onPaywall = {},
|
||||
onBack = {}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,25 +4,30 @@ import android.util.Log
|
|||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.core.navigation.AppRoute
|
||||
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.usecase.GameSessionManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SpinWheelUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val isLoading: Boolean = false,
|
||||
val categoryName: String = "",
|
||||
val isSpinning: Boolean = false,
|
||||
val spunAndReady: Boolean = false,
|
||||
val selectedLength: SessionLength = SessionLength.STANDARD,
|
||||
val hasPremium: Boolean = false,
|
||||
val isPaired: Boolean = true,
|
||||
val error: String? = null,
|
||||
val navigateTo: String? = null
|
||||
)
|
||||
|
|
@ -32,16 +37,37 @@ class SpinWheelViewModel @Inject constructor(
|
|||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: QuestionRepository,
|
||||
private val sessionStore: LocalWheelSessionStore,
|
||||
private val gameSessionManager: GameSessionManager
|
||||
private val gameSessionManager: GameSessionManager,
|
||||
private val entitlementChecker: EntitlementChecker
|
||||
) : ViewModel() {
|
||||
|
||||
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()
|
||||
|
||||
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() {
|
||||
|
|
@ -50,43 +76,77 @@ class SpinWheelViewModel @Inject constructor(
|
|||
.onFailure { Log.w(TAG, "Could not load wheel category", it) }
|
||||
.getOrNull()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
categoryName = category?.displayName ?: categoryId
|
||||
)
|
||||
it.copy(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() {
|
||||
if (_uiState.value.isSpinning) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSpinning = true, 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() }
|
||||
_uiState.update { it.copy(isSpinning = true, spunAndReady = false, error = null) }
|
||||
|
||||
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 {
|
||||
it.copy(isSpinning = false, error = "No questions found for this category.")
|
||||
it.copy(isSpinning = false, error = "No questions found. Try another spin.")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val category = runCatching { repository.getCategoryById(categoryId) }
|
||||
.onFailure { Log.w(TAG, "Could not load wheel category for session", it) }
|
||||
.getOrNull()
|
||||
val (category, questions) = result
|
||||
sessionStore.activeSession = LocalWheelSession(
|
||||
categoryId = categoryId,
|
||||
categoryName = category?.displayName ?: categoryId,
|
||||
categoryId = category.id,
|
||||
categoryName = category.displayName,
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
val userId = gameSessionManager.currentUserId
|
||||
|
|
@ -109,18 +169,17 @@ class SpinWheelViewModel @Inject constructor(
|
|||
}.getOrNull() ?: false
|
||||
|
||||
if (hasActive) {
|
||||
_uiState.update {
|
||||
it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER)
|
||||
}
|
||||
_uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val effectiveCategoryId = sessionStore.activeSession?.categoryId ?: categoryId
|
||||
val questionIds = sessionStore.activeSession?.questions?.map { it.id }
|
||||
val startResult = runCatching {
|
||||
gameSessionManager.startGame(
|
||||
userId = userId,
|
||||
gameType = GameType.WHEEL,
|
||||
categoryId = categoryId,
|
||||
categoryId = effectiveCategoryId,
|
||||
questionIds = questionIds
|
||||
)
|
||||
}.getOrElse { Result.failure(it) }
|
||||
|
|
@ -141,9 +200,17 @@ class SpinWheelViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun setLength(len: SessionLength) {
|
||||
// Resets spunAndReady so the user re-spins with the new count.
|
||||
_uiState.update { it.copy(selectedLength = len, spunAndReady = false) }
|
||||
fun onChooseCategory() {
|
||||
val target = if (_uiState.value.hasPremium) AppRoute.CATEGORY_PICKER else AppRoute.PAYWALL
|
||||
_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() {
|
||||
|
|
@ -152,5 +219,7 @@ class SpinWheelViewModel @Inject constructor(
|
|||
|
||||
companion object {
|
||||
private const val TAG = "SpinWheelViewModel"
|
||||
const val SPIN_DURATION_MS = 3500L
|
||||
private const val QUESTION_COUNT = 10
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ service cloud.firestore {
|
|||
== get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId);
|
||||
allow create, update: if isOwner(uid)
|
||||
&& 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']);
|
||||
allow delete: if false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue