feat: nav routes, play hub, spin wheel screen + viewmodel, firestore rules
This commit is contained in:
parent
ecc41a77d2
commit
7d5fc11366
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue