feat: enforce one active game per couple with partner notifications
This commit is contained in:
parent
2677e38514
commit
c58b1c6326
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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? =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue