From 7d5fc11366370655d437d0c7b21ba61776fbc037 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 22 Jun 2026 10:53:05 -0500 Subject: [PATCH] feat: nav routes, play hub, spin wheel screen + viewmodel, firestore rules --- .../closer/core/navigation/AppNavigation.kt | 10 +- .../app/closer/core/navigation/AppRoute.kt | 3 + .../java/app/closer/ui/play/PlayHubScreen.kt | 4 +- .../app/closer/ui/wheel/SpinWheelScreen.kt | 230 +++++++++++++----- .../app/closer/ui/wheel/SpinWheelViewModel.kt | 131 +++++++--- firestore.rules | 2 +- 6 files changed, 280 insertions(+), 100 deletions(-) diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 21f611ea..0de477bf 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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( diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index d63d1720..1d61eceb 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt index 6bbf4b92..e4e85fae 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt @@ -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() diff --git a/app/src/main/java/app/closer/ui/wheel/SpinWheelScreen.kt b/app/src/main/java/app/closer/ui/wheel/SpinWheelScreen.kt index 3ca90cf7..326c160b 100644 --- a/app/src/main/java/app/closer/ui/wheel/SpinWheelScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/SpinWheelScreen.kt @@ -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 = {} ) } diff --git a/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt b/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt index d7c6e889..55cc4e2d 100644 --- a/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt @@ -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 = _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> { + 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 } } diff --git a/firestore.rules b/firestore.rules index ee498edc..c03bba1c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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; }