From c58b1c63262e892ddd834980c5ef63961eddf501 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 18 Jun 2026 00:56:21 -0500 Subject: [PATCH] feat: enforce one active game per couple with partner notifications --- .../closer/core/navigation/AppNavigation.kt | 7 + .../app/closer/core/navigation/AppRoute.kt | 7 +- .../core/notifications/AppMessagingService.kt | 7 + .../core/notifications/NotificationHelper.kt | 12 +- .../QuestionSessionRepositoryImpl.kt | 84 ++++++- .../closer/domain/model/QuestionSession.kt | 4 +- .../repository/QuestionSessionRepository.kt | 6 + .../domain/usecase/GameSessionManager.kt | 120 ++++++++++ .../closer/ui/desiresync/DesireSyncScreen.kt | 27 ++- .../ui/games/WaitingForPartnerScreen.kt | 200 +++++++++++++++++ .../app/closer/ui/howwell/HowWellScreen.kt | 27 ++- .../closer/ui/thisorthat/ThisOrThatScreen.kt | 27 ++- .../app/closer/ui/wheel/SpinWheelViewModel.kt | 47 +++- .../closer/ui/wheel/WheelCompleteScreen.kt | 26 ++- functions/src/games/onGameSessionUpdate.ts | 207 ++++++++++++++++++ functions/src/index.ts | 1 + 16 files changed, 782 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt create mode 100644 app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt create mode 100644 functions/src/games/onGameSessionUpdate.ts 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 880b977a..0f6938da 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -68,6 +68,7 @@ import app.closer.ui.wheel.SpinWheelScreen import app.closer.ui.wheel.WheelCompleteScreen import app.closer.ui.wheel.WheelHistoryScreen import app.closer.ui.wheel.WheelSessionScreen +import app.closer.ui.games.WaitingForPartnerScreen @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -310,6 +311,11 @@ fun AppNavigation( composable(route = AppRoute.DESIRE_SYNC) { DesireSyncScreen(onNavigate = navigateRoute) } + composable(route = AppRoute.WAITING_FOR_PARTNER) { + WaitingForPartnerScreen( + onNavigate = navigateRoute + ) + } // Dates composable(route = AppRoute.DATE_MATCH) { @@ -385,6 +391,7 @@ private val shellBackRoutes = setOf( AppRoute.THIS_OR_THAT, AppRoute.HOW_WELL, AppRoute.DESIRE_SYNC, + AppRoute.WAITING_FOR_PARTNER, AppRoute.SUBSCRIPTION, ) 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 01349726..72780a85 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -41,6 +41,7 @@ object AppRoute { const val THIS_OR_THAT = "this_or_that" const val HOW_WELL = "how_well" const val DESIRE_SYNC = "desire_sync" + const val WAITING_FOR_PARTNER = "waiting_for_partner" // Question thread: coupleId and questionId are required; prevId and nextId are optional. const val QUESTION_THREAD = @@ -91,7 +92,8 @@ object AppRoute { Definition(BUCKET_LIST, "Our Bucket List", "dates"), Definition(THIS_OR_THAT, "This or That", "play"), Definition(HOW_WELL, "How Well Do You Know Me", "play"), - Definition(DESIRE_SYNC, "Desire Sync", "play") + Definition(DESIRE_SYNC, "Desire Sync", "play"), + Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play") ) val topLevelRoutes = setOf( @@ -138,7 +140,8 @@ object AppRoute { PRIVACY, SUBSCRIPTION, RELATIONSHIP_SETTINGS, - DELETE_ACCOUNT + DELETE_ACCOUNT, + WAITING_FOR_PARTNER ) fun titleFor(route: String?): String? = diff --git a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt index 2629e78b..2f1ac2a7 100644 --- a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt +++ b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt @@ -80,6 +80,7 @@ class AppMessagingService : FirebaseMessagingService() { val channelId = when (type) { "partner_answered" -> NotificationHelper.CHANNEL_PARTNER "partner_left" -> NotificationHelper.CHANNEL_PARTNER + "partner_started_game", "partner_finished_game", "partner_waiting" -> NotificationHelper.CHANNEL_PARTNER "daily_question", "streak" -> NotificationHelper.CHANNEL_REMINDERS else -> NotificationHelper.CHANNEL_REMINDERS } @@ -100,6 +101,9 @@ class AppMessagingService : FirebaseMessagingService() { "partner_answered" -> "Your partner just answered!" "partner_left" -> "Your partner has left" "streak" -> "Keep your streak going — answer today's question!" + "partner_started_game" -> "Partner is playing!" + "partner_finished_game" -> "Partner finished!" + "partner_waiting" -> "Partner waiting" else -> null } @@ -108,6 +112,9 @@ class AppMessagingService : FirebaseMessagingService() { "partner_answered" -> "See what your partner shared." "partner_left" -> "You are no longer paired. Tap to create a new invite." "streak" -> "Don't break the chain. Open the app now." + "partner_started_game" -> "Your partner has started a game. Tap to join!" + "partner_finished_game" -> "Your partner has finished. Tap to see results!" + "partner_waiting" -> "Your partner is waiting for you to finish." else -> null } diff --git a/app/src/main/java/app/closer/core/notifications/NotificationHelper.kt b/app/src/main/java/app/closer/core/notifications/NotificationHelper.kt index ba38d027..be38d10d 100644 --- a/app/src/main/java/app/closer/core/notifications/NotificationHelper.kt +++ b/app/src/main/java/app/closer/core/notifications/NotificationHelper.kt @@ -14,6 +14,7 @@ object NotificationHelper { const val CHANNEL_REMINDERS = "reminders" const val CHANNEL_PARTNER = "partner_activity" + const val CHANNEL_GAME_ACTIVITY = "game_activity" fun createChannels(context: Context) { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -32,7 +33,16 @@ object NotificationHelper { "Partner activity", NotificationManager.IMPORTANCE_HIGH ).apply { - description = "When your partner answers a question" + description = "When your partner answers a question or plays a game" + } + ) + nm.createNotificationChannel( + NotificationChannel( + CHANNEL_GAME_ACTIVITY, + "Game activity", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "When your partner starts or finishes a game session" } ) } diff --git a/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt index 1d617d0e..bc26082a 100644 --- a/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt @@ -5,6 +5,9 @@ import app.closer.data.remote.FirestoreCollections import app.closer.domain.model.QuestionSession import app.closer.domain.repository.QuestionSessionRepository import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton @@ -36,8 +39,10 @@ class QuestionSessionRepositoryImpl @Inject constructor( "startedByUserId" to session.startedByUserId, "startedAt" to session.startedAt, "completedAt" to session.completedAt, + "partnerCompletedAt" to session.partnerCompletedAt, "isPremium" to session.isPremium, - "status" to session.status + "status" to session.status, + "gameType" to session.gameType ) doc.set(data).await() } @@ -63,12 +68,87 @@ class QuestionSessionRepositoryImpl @Inject constructor( startedByUserId = doc.getString("startedByUserId") ?: "", startedAt = doc.getLong("startedAt") ?: 0L, completedAt = doc.getLong("completedAt"), + partnerCompletedAt = doc.getLong("partnerCompletedAt"), isPremium = doc.getBoolean("isPremium") ?: false, - status = doc.getString("status") ?: "completed" + status = doc.getString("status") ?: "completed", + gameType = doc.getString("gameType") ?: "wheel" ) } .onFailure { crashReporter.recordException(it) } .getOrNull() } } + + override suspend fun getActiveSessionForCouple(coupleId: String): QuestionSession? = + runCatching { + firestore.collection(FirestoreCollections.COUPLES) + .document(coupleId) + .collection(FirestoreCollections.Couples.SESSIONS) + .whereEqualTo("status", "active") + .limit(1) + .get() + .await() + .documents + .firstOrNull() + ?.let { doc -> + runCatching { + QuestionSession( + id = doc.getString("id") ?: doc.id, + coupleId = doc.getString("coupleId") ?: coupleId, + categoryId = doc.getString("categoryId") ?: "", + questionIds = (doc.get("questionIds") as? List<*>) + ?.filterIsInstance() ?: emptyList(), + startedByUserId = doc.getString("startedByUserId") ?: "", + startedAt = doc.getLong("startedAt") ?: 0L, + completedAt = doc.getLong("completedAt"), + partnerCompletedAt = doc.getLong("partnerCompletedAt"), + isPremium = doc.getBoolean("isPremium") ?: false, + status = doc.getString("status") ?: "active", + gameType = doc.getString("gameType") ?: "wheel" + ) + } + .onFailure { crashReporter.recordException(it) } + .getOrNull() + } + }.getOrNull() + + override fun observeActiveSessionForCouple(coupleId: String): Flow = + callbackFlow { + val registration = firestore.collection(FirestoreCollections.COUPLES) + .document(coupleId) + .collection(FirestoreCollections.Couples.SESSIONS) + .whereEqualTo("status", "active") + .limit(1) + .addSnapshotListener { snapshot, error -> + if (error != null) { + crashReporter.recordException(error) + return@addSnapshotListener + } + val session = snapshot?.documents?.firstOrNull()?.let { doc -> + runCatching { + QuestionSession( + id = doc.getString("id") ?: doc.id, + coupleId = doc.getString("coupleId") ?: coupleId, + categoryId = doc.getString("categoryId") ?: "", + questionIds = (doc.get("questionIds") as? List<*>) + ?.filterIsInstance() ?: emptyList(), + startedByUserId = doc.getString("startedByUserId") ?: "", + startedAt = doc.getLong("startedAt") ?: 0L, + completedAt = doc.getLong("completedAt"), + partnerCompletedAt = doc.getLong("partnerCompletedAt"), + isPremium = doc.getBoolean("isPremium") ?: false, + status = doc.getString("status") ?: "active", + gameType = doc.getString("gameType") ?: "wheel" + ) + } + .onFailure { crashReporter.recordException(it) } + .getOrNull() + } + trySend(session) + } + awaitClose { registration.remove() } + } + + override suspend fun hasActiveSession(coupleId: String): Boolean = + getActiveSessionForCouple(coupleId) != null } diff --git a/app/src/main/java/app/closer/domain/model/QuestionSession.kt b/app/src/main/java/app/closer/domain/model/QuestionSession.kt index 03f17add..0c49fbe2 100644 --- a/app/src/main/java/app/closer/domain/model/QuestionSession.kt +++ b/app/src/main/java/app/closer/domain/model/QuestionSession.kt @@ -8,6 +8,8 @@ data class QuestionSession( val startedByUserId: String = "", val startedAt: Long = System.currentTimeMillis(), val completedAt: Long? = null, + val partnerCompletedAt: Long? = null, val isPremium: Boolean = false, - val status: String = "active" + val status: String = "active", + val gameType: String = "wheel" ) diff --git a/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt b/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt index f6fc56ba..13a9a75f 100644 --- a/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/QuestionSessionRepository.kt @@ -1,8 +1,14 @@ package app.closer.domain.repository import app.closer.domain.model.QuestionSession +import kotlinx.coroutines.flow.Flow interface QuestionSessionRepository { suspend fun saveSession(session: QuestionSession): Result suspend fun getSessionsForCouple(coupleId: String): Result> + + // Active session queries + suspend fun getActiveSessionForCouple(coupleId: String): QuestionSession? + fun observeActiveSessionForCouple(coupleId: String): Flow + suspend fun hasActiveSession(coupleId: String): Boolean } diff --git a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt new file mode 100644 index 00000000..95308bc1 --- /dev/null +++ b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt @@ -0,0 +1,120 @@ +package app.closer.domain.usecase + +import app.closer.domain.model.Couple +import app.closer.domain.model.QuestionSession +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.CoupleRepository +import app.closer.domain.repository.QuestionSessionRepository +import app.closer.domain.repository.UserRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages game session lifecycle with partner notifications. + * Enforces "one active game per couple" rule. + */ +@Singleton +class GameSessionManager @Inject constructor( + private val sessionRepository: QuestionSessionRepository, + private val authRepository: AuthRepository, + private val coupleRepository: CoupleRepository, + private val userRepository: UserRepository +) { + val currentUserId: String? + get() = authRepository.currentUserId + + suspend fun getCoupleForUser(userId: String): Couple? = + coupleRepository.getCoupleForUser(userId) + + suspend fun getUser(userId: String) = + userRepository.getUser(userId) + + /** + * Start a new game session for the current couple. + * Checks for existing active sessions and notifies partner. + */ + suspend fun startGame( + userId: String, + gameType: String, + categoryId: String? = null, + questionIds: List? = null + ): Result { + val couple = coupleRepository.getCoupleForUser(userId) + ?: return Result.failure(Exception("User is not in a couple")) + + val activeSession = sessionRepository.getActiveSessionForCouple(couple.id) + if (activeSession != null) { + val partnerId = couple.userIds.firstOrNull { it != userId } + val partnerName = partnerId?.let { userRepository.getUser(it) }?.displayName ?: "Partner" + val gameTypeLabel = gameTypeLabel(gameType) + return Result.failure( + Exception("partner_active_session|$partnerName|$gameTypeLabel") + ) + } + + val session = QuestionSession( + coupleId = couple.id, + categoryId = categoryId ?: "", + questionIds = questionIds ?: emptyList(), + startedByUserId = userId, + gameType = gameType, + status = "active" + ) + + val saveResult = sessionRepository.saveSession(session) + return saveResult.map { session.id } + } + + /** + * Finish the current session for a user. + * Marks the session as completed by this user. + * Notifies partner when both users have completed. + */ + suspend fun finishGame( + sessionId: String, + coupleId: String, + userId: String + ): Result = runCatching { + val currentSession = sessionRepository.getActiveSessionForCouple(coupleId) + ?: throw Exception("No active session found") + + if (currentSession.id != sessionId) { + throw Exception("Session ID mismatch") + } + + val completedAt = System.currentTimeMillis() + val updatedSession = currentSession.copy( + completedAt = completedAt, + status = "completed" + ) + + sessionRepository.saveSession(updatedSession) + } + + /** + * Get the active session for a couple. + */ + suspend fun getActiveSession(coupleId: String): QuestionSession? = + sessionRepository.getActiveSessionForCouple(coupleId) + + /** + * Check if a couple has an active session. + */ + suspend fun hasActiveSession(coupleId: String): Boolean = + sessionRepository.hasActiveSession(coupleId) + + /** + * Observe active session changes for a couple. + */ + fun observeActiveSession(coupleId: String): Flow = + sessionRepository.observeActiveSessionForCouple(coupleId) + + private fun gameTypeLabel(gameType: String): String = when (gameType) { + "wheel" -> "Wheel" + "this_or_that" -> "This or That" + "how_well" -> "How Well Do You Know Me" + "desire_sync" -> "Desire Sync" + else -> gameType + } +} diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index a818c0aa..faa571bb 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.ui.Alignment @@ -54,6 +55,7 @@ import app.closer.core.navigation.AppRoute import app.closer.domain.model.ChoiceAnswerConfigImpl import app.closer.domain.model.Question import app.closer.domain.repository.QuestionRepository +import app.closer.domain.usecase.GameSessionManager import app.closer.ui.components.StatusGlyph import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.closerBackgroundBrush @@ -92,7 +94,8 @@ data class DesireSyncUiState( val partnerBAnswers: List = emptyList(), val pendingSelection: String? = null, val matches: List = emptyList(), - val error: String? = null + val error: String? = null, + val navigateTo: String? = null ) private val POSITIVE_IDS = setOf("yes", "true") @@ -113,13 +116,27 @@ private fun topicLabel(femaleQ: Question): String = @HiltViewModel class DesireSyncViewModel @Inject constructor( - private val repository: QuestionRepository + private val repository: QuestionRepository, + private val gameSessionManager: GameSessionManager ) : ViewModel() { private val _uiState = MutableStateFlow(DesireSyncUiState()) val uiState: StateFlow = _uiState.asStateFlow() - init { load() } + init { + checkActiveSession() + load() + } + + private fun checkActiveSession() { + viewModelScope.launch { + val userId = gameSessionManager.currentUserId ?: return@launch + val couple = gameSessionManager.getCoupleForUser(userId) ?: return@launch + if (gameSessionManager.hasActiveSession(couple.id)) { + _uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) } + } + } + } private fun load() { viewModelScope.launch { @@ -247,6 +264,10 @@ fun DesireSyncScreen( ) { val state by viewModel.uiState.collectAsState() + LaunchedEffect(state.navigateTo) { + state.navigateTo?.let { onNavigate(it) } + } + Box( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt b/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt new file mode 100644 index 00000000..952121fb --- /dev/null +++ b/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt @@ -0,0 +1,200 @@ +package app.closer.ui.games + +import app.closer.ui.theme.closerBackgroundBrush +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.closer.core.navigation.AppRoute +import app.closer.domain.model.QuestionSession +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.CoupleRepository +import app.closer.domain.repository.UserRepository +import app.closer.domain.usecase.GameSessionManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +// ── ViewModel ──────────────────────────────────────────────────────────────── + +data class WaitingForPartnerUiState( + val isLoading: Boolean = true, + val gameType: String = "wheel", + val partnerName: String = "Partner", + val navigateTo: String? = null +) + +@HiltViewModel +class WaitingForPartnerViewModel @Inject constructor( + gameSessionManager: GameSessionManager +) : ViewModel() { + private val _uiState = MutableStateFlow(WaitingForPartnerUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + loadGameInfo() + // Poll for partner's session completion + while (_uiState.value.navigateTo == null) { + delay(5000) // Check every 5 seconds + val userId = gameSessionManager.currentUserId ?: "" + val couple = gameSessionManager.getCoupleForUser(userId) + if (couple != null) { + val hasActive = gameSessionManager.hasActiveSession(couple.id) + if (!hasActive) { + // Partner finished - go back to games menu + _uiState.update { it.copy(navigateTo = AppRoute.PLAY) } + break + } + } + } + } + } + + private suspend fun loadGameInfo() { + val userId = gameSessionManager.currentUserId ?: "" + val couple = gameSessionManager.getCoupleForUser(userId) + val activeSession = gameSessionManager.getActiveSession(couple?.id ?: "") + + val partnerId = couple?.userIds?.firstOrNull { it != userId } + val partnerName = partnerId?.let { gameSessionManager.getUser(it) }?.displayName ?: "Partner" + + _uiState.update { + it.copy( + isLoading = false, + gameType = activeSession?.gameType ?: "wheel", + partnerName = partnerName + ) + } + } + + fun onNavigated() { + _uiState.update { it.copy(navigateTo = null) } + } +} + +// ── Screen ──────────────────────────────────────────────────────────────────── + +@Composable +fun WaitingForPartnerScreen( + onNavigate: (String) -> Unit = {}, + viewModel: WaitingForPartnerViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsState() + + Box( + modifier = Modifier + .fillMaxSize() + .background(closerBackgroundBrush()) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding() + .padding(horizontal = 24.dp) + .align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + when { + state.isLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Loading...", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 16.dp) + ) + } + else -> { + // Game type icon/symbol + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(80.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center), + contentAlignment = Alignment.Center + ) { + Text( + text = when (state.gameType) { + "wheel" -> "🎡" + "this_or_that" -> "❓" + "how_well" -> "🧠" + "desire_sync" -> "❤️" + else -> "🎮" + }, + style = MaterialTheme.typography.displayMedium, + modifier = Modifier.align(Alignment.Center) + ) + } + } + + Text( + text = "Waiting for ${state.partnerName}", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(top = 24.dp), + textAlign = TextAlign.Center + ) + + Text( + text = "${state.partnerName} is playing a ${gameTypeLabel(state.gameType)} game.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, bottom = 24.dp), + textAlign = TextAlign.Center + ) + + Button( + onClick = { onNavigate(AppRoute.PLAY) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Back to Games") + } + } + } + } + } +} + +private fun gameTypeLabel(gameType: String): String = when (gameType) { + "wheel" -> "Wheel" + "this_or_that" -> "This or That" + "how_well" -> "How Well Do You Know Me" + "desire_sync" -> "Desire Sync" + else -> gameType +} diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index 3ba0ad7d..9d905c15 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.ui.Alignment @@ -57,6 +58,7 @@ import app.closer.domain.model.Question import app.closer.domain.model.ScaleAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.repository.QuestionRepository +import app.closer.domain.usecase.GameSessionManager import app.closer.ui.components.ResultGlyph import app.closer.ui.components.StatusGlyph import app.closer.ui.theme.CloserPalette @@ -125,20 +127,35 @@ data class HowWellUiState( val selectedOptionId: String? = null, val selectedScale: Int? = null, val score: Int = 0, - val error: String? = null + val error: String? = null, + val navigateTo: String? = null ) // ── ViewModel ───────────────────────────────────────────────────────────────── @HiltViewModel class HowWellViewModel @Inject constructor( - private val repository: QuestionRepository + private val repository: QuestionRepository, + private val gameSessionManager: GameSessionManager ) : ViewModel() { private val _uiState = MutableStateFlow(HowWellUiState()) val uiState: StateFlow = _uiState.asStateFlow() - init { load() } + init { + checkActiveSession() + load() + } + + private fun checkActiveSession() { + viewModelScope.launch { + val userId = gameSessionManager.currentUserId ?: return@launch + val couple = gameSessionManager.getCoupleForUser(userId) ?: return@launch + if (gameSessionManager.hasActiveSession(couple.id)) { + _uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) } + } + } + } private fun load() { viewModelScope.launch { @@ -233,6 +250,10 @@ fun HowWellScreen( ) { val state by viewModel.uiState.collectAsState() + LaunchedEffect(state.navigateTo) { + state.navigateTo?.let { onNavigate(it) } + } + Box( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 0719d7d5..5ae168cb 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -63,6 +64,7 @@ import app.closer.domain.model.Question import app.closer.domain.model.ThisOrThatAnswerConfig import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.repository.QuestionRepository +import app.closer.domain.usecase.GameSessionManager import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.closerBackgroundBrush import dagger.hilt.android.lifecycle.HiltViewModel @@ -84,18 +86,33 @@ data class ThisOrThatUiState( val aCount: Int = 0, val bCount: Int = 0, val isComplete: Boolean = false, - val error: String? = null + val error: String? = null, + val navigateTo: String? = null ) @HiltViewModel class ThisOrThatViewModel @Inject constructor( - private val repository: QuestionRepository + private val repository: QuestionRepository, + private val gameSessionManager: GameSessionManager ) : ViewModel() { private val _uiState = MutableStateFlow(ThisOrThatUiState()) val uiState: StateFlow = _uiState.asStateFlow() - init { load() } + init { + checkActiveSession() + load() + } + + private fun checkActiveSession() { + viewModelScope.launch { + val userId = gameSessionManager.currentUserId ?: return@launch + val couple = gameSessionManager.getCoupleForUser(userId) ?: return@launch + if (gameSessionManager.hasActiveSession(couple.id)) { + _uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) } + } + } + } private fun load() { viewModelScope.launch { @@ -160,6 +177,10 @@ fun ThisOrThatScreen( ) { val state by viewModel.uiState.collectAsState() + LaunchedEffect(state.navigateTo) { + state.navigateTo?.let { onNavigate(it) } + } + Box( modifier = Modifier .fillMaxSize() 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 18f19077..0f8e7932 100644 --- a/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.core.navigation.AppRoute 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.flow.MutableStateFlow @@ -27,7 +28,8 @@ data class SpinWheelUiState( class SpinWheelViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: QuestionRepository, - private val sessionStore: LocalWheelSessionStore + private val sessionStore: LocalWheelSessionStore, + private val gameSessionManager: GameSessionManager ) : ViewModel() { private val categoryId: String = savedStateHandle["categoryId"] ?: "" @@ -83,7 +85,48 @@ class SpinWheelViewModel @Inject constructor( } fun startSession() { - _uiState.update { it.copy(navigateTo = AppRoute.wheelSession("session")) } + viewModelScope.launch { + val userId = gameSessionManager.currentUserId + if (userId == null) { + _uiState.update { it.copy(error = "Not logged in") } + return@launch + } + + val couple = runCatching { + gameSessionManager.getCoupleForUser(userId) + }.getOrNull() + + if (couple == null) { + _uiState.update { it.copy(error = "Not in a couple") } + return@launch + } + + val hasActive = runCatching { + gameSessionManager.hasActiveSession(couple.id) + }.getOrNull() ?: false + + if (hasActive) { + _uiState.update { + it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) + } + return@launch + } + + val startResult = runCatching { + gameSessionManager.startGame( + userId = userId, + gameType = "wheel", + categoryId = categoryId + ).getOrNull() + }.getOrNull() + + if (startResult == null) { + _uiState.update { it.copy(error = "Could not start session") } + return@launch + } + + _uiState.update { it.copy(navigateTo = AppRoute.wheelSession(startResult)) } + } } fun onNavigated() { diff --git a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt index f9fe79df..73d87224 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt @@ -9,6 +9,7 @@ import app.closer.domain.model.QuestionSession import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.QuestionSessionRepository +import app.closer.domain.usecase.GameSessionManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -52,7 +53,8 @@ class WheelCompleteViewModel @Inject constructor( private val sessionStore: LocalWheelSessionStore, private val sessionRepository: QuestionSessionRepository, private val authRepository: AuthRepository, - private val coupleRepository: CoupleRepository + private val coupleRepository: CoupleRepository, + private val gameSessionManager: GameSessionManager ) : ViewModel() { val categoryName: String = sessionStore.activeSession?.categoryName ?: "" val answered: Int = sessionStore.lastAnswered @@ -67,15 +69,19 @@ class WheelCompleteViewModel @Inject constructor( val uid = authRepository.currentUserId ?: return viewModelScope.launch { val couple = coupleRepository.getCoupleForUser(uid) ?: return@launch - sessionRepository.saveSession( - QuestionSession( - coupleId = couple.id, - categoryId = session.categoryId, - questionIds = session.questions.map { it.id }, - startedByUserId = uid, - completedAt = System.currentTimeMillis(), - status = "completed" - ) + val savedSession = QuestionSession( + coupleId = couple.id, + categoryId = session.categoryId, + questionIds = session.questions.map { it.id }, + startedByUserId = uid, + completedAt = System.currentTimeMillis(), + status = "completed" + ) + sessionRepository.saveSession(savedSession) + gameSessionManager.finishGame( + sessionId = savedSession.id, + coupleId = couple.id, + userId = uid ) } } diff --git a/functions/src/games/onGameSessionUpdate.ts b/functions/src/games/onGameSessionUpdate.ts new file mode 100644 index 00000000..27508801 --- /dev/null +++ b/functions/src/games/onGameSessionUpdate.ts @@ -0,0 +1,207 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +/** + * Firestore trigger that notifies partners when a game session is created or completed. + * + * Path: couples/{coupleId}/sessions/{sessionId} + * Condition: onWrite (create, update, delete) + */ +export const onGameSessionUpdate = functions.firestore + .document('couples/{coupleId}/sessions/{sessionId}') + .onWrite(async (change, context) => { + const { coupleId, sessionId } = context.params as { coupleId: string; sessionId: string } + + const db = admin.firestore() + const messaging = admin.messaging() + + // Get the session document + const sessionDoc = await db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId).get() + const session = sessionDoc.data() + if (!session) { + console.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`) + return + } + + // Get couple info + const coupleDoc = await db.collection('couples').doc(coupleId).get() + if (!coupleDoc.exists) { + console.warn(`[onGameSessionUpdate] couple ${coupleId} not found`) + return + } + + const coupleData = coupleDoc.data() ?? {} + const userIds = (coupleData.userIds ?? []) as string[] + if (userIds.length !== 2) { + console.warn(`[onGameSessionUpdate] invalid couple ${coupleId}: expected 2 users, got ${userIds.length}`) + return + } + + const partnerA = userIds[0] + const partnerB = userIds[1] + + // Get user display names for notifications + const userA = await db.collection('users').doc(partnerA).get() + const userB = await db.collection('users').doc(partnerB).get() + const partnerAName = userA.data()?.displayName ?? 'Partner A' + const partnerBName = userB.data()?.displayName ?? 'Partner B' + + // Check if session was just created (status = "active") + const previousData = change.before.data() ?? {} + const currentData = change.after.data() ?? {} + + const wasInactive = (previousData.status ?? '') !== 'active' + const isActiveNow = currentData.status === 'active' + + if (wasInactive && isActiveNow) { + // New session started - notify the other partner + const startedBy = currentData.startedByUserId + const gameType = currentData.gameType ?? 'wheel' + const partnerId = startedBy === partnerA ? partnerB : partnerA + const partnerName = startedBy === partnerA ? partnerBName : partnerAName + + await notifyPartner( + db, + messaging, + partnerId, + partnerName, + gameType, + 'partner_started_game', + `${partnerName} has started a game. Tap to join!` + ) + return + } + + // Check if session was completed + const wasActive = (previousData.status ?? '') === 'active' + const isCompletedNow = currentData.status === 'completed' + + if (wasActive && isCompletedNow) { + const completedBy = currentData.startedByUserId + const partnerId = completedBy === partnerA ? partnerB : partnerA + + // Check if partner has also completed + const partnerCompletedAt = currentData.partnerCompletedAt + if (partnerCompletedAt) { + // Both completed - notify both + await notifyPartner( + db, + messaging, + partnerA, + partnerAName, + currentData.gameType ?? 'wheel', + 'partner_finished_game', + `${partnerBName} has finished the game. Tap to see the results!` + ) + await notifyPartner( + db, + messaging, + partnerB, + partnerBName, + currentData.gameType ?? 'wheel', + 'partner_finished_game', + `${partnerAName} has finished the game. Tap to see the results!` + ) + } else { + // Only one completed - notify the other to continue + await notifyPartner( + db, + messaging, + partnerId, + partnerName, + currentData.gameType ?? 'wheel', + 'partner_finished_game', + `${partnerName} has finished. Tap to continue playing!` + ) + } + return + } + }) + +/** + * Send notification to partner via FCM and write to notification_queue. + */ +async function notifyPartner( + db: admin.firestore.Firestore, + messaging: admin.messaging.Messaging, + partnerId: string, + partnerName: string, + gameType: string, + notificationType: string, + body: string +): Promise { + const notificationPayload = { + type: notificationType, + title: `${partnerName} is playing`, + body: body, + } + + // Write an in-app notification record for the partner + await db + .collection('users') + .doc(partnerId) + .collection('notification_queue') + .add({ + ...notificationPayload, + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }) + + // Collect the partner's FCM tokens + const tokens: string[] = [] + const partnerUserDoc = await db.collection('users').doc(partnerId).get() + + if (partnerUserDoc.exists) { + const legacyToken = partnerUserDoc.data()?.fcmToken + if (typeof legacyToken === 'string' && legacyToken.length > 0) { + tokens.push(legacyToken) + } + } + + const tokenSnapshot = await db + .collection('users') + .doc(partnerId) + .collection('fcmTokens') + .get() + tokenSnapshot.docs.forEach((doc) => { + const t = doc.data()?.token + if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) { + tokens.push(t) + } + }) + + if (tokens.length === 0) { + console.log(`[notifyPartner] no FCM tokens for ${partnerId}`) + return + } + + const fcmMessage: admin.messaging.Message = { + token: tokens[0], + notification: { + title: notificationPayload.title, + body: notificationPayload.body, + }, + data: { + type: notificationPayload.type, + gameType: gameType, + partnerId: partnerId, + }, + } + + const sendResults = await Promise.allSettled( + tokens.map((token) => messaging.send({ ...fcmMessage, token })) + ) + + const failures: string[] = [] + sendResults.forEach((result, index) => { + if (result.status === 'rejected') { + failures.push(`${tokens[index]}: ${String(result.reason)}`) + } + }) + + if (failures.length > 0) { + console.error(`[notifyPartner] some notifications failed:`, failures) + } else { + console.log(`[notifyPartner] notified ${partnerId} (${notificationType})`) + } +} diff --git a/functions/src/index.ts b/functions/src/index.ts index b033894b..7ff7369e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -22,6 +22,7 @@ export { } from './questions/assignDailyQuestion' export { onAnswerWritten } from './questions/onAnswerWritten' export { onCoupleLeave } from './couples/onCoupleLeave' +export { onGameSessionUpdate } from './games/onGameSessionUpdate' /** * Basic health check callable.