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 4dcafa688e
commit 5ac4f40bf6
6 changed files with 280 additions and 100 deletions

View File

@ -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(

View File

@ -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,

View File

@ -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()

View File

@ -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 = {}
)
}

View File

@ -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
}
}

View File

@ -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;
}