feat: enforce one active game per couple with partner notifications

This commit is contained in:
null 2026-06-18 00:56:21 -05:00
parent 2677e38514
commit c58b1c6326
16 changed files with 782 additions and 27 deletions

View File

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

View File

@ -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? =

View File

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

View File

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

View File

@ -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<String>() ?: 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<QuestionSession?> =
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<String>() ?: 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
}

View File

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

View File

@ -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<Unit>
suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>>
// Active session queries
suspend fun getActiveSessionForCouple(coupleId: String): QuestionSession?
fun observeActiveSessionForCouple(coupleId: String): Flow<QuestionSession?>
suspend fun hasActiveSession(coupleId: String): Boolean
}

View File

@ -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<String>? = null
): Result<String> {
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<Unit> = 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<QuestionSession?> =
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
}
}

View File

@ -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<String> = emptyList(),
val pendingSelection: String? = null,
val matches: List<DesireMatch> = 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<DesireSyncUiState> = _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()

View File

@ -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<WaitingForPartnerUiState> = _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
}

View File

@ -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<HowWellUiState> = _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()

View File

@ -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<ThisOrThatUiState> = _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()

View File

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

View File

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

View File

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

View File

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