diff --git a/app/src/main/java/app/closer/core/billing/CouplePremiumChecker.kt b/app/src/main/java/app/closer/core/billing/CouplePremiumChecker.kt index c4396549..c1716789 100644 --- a/app/src/main/java/app/closer/core/billing/CouplePremiumChecker.kt +++ b/app/src/main/java/app/closer/core/billing/CouplePremiumChecker.kt @@ -1,26 +1,48 @@ package app.closer.core.billing import app.closer.data.remote.FirestoreCollections +import app.closer.domain.repository.CoupleRepository +import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import javax.inject.Inject import javax.inject.Singleton /** - * Couple-shared premium: media/reactions unlock if EITHER partner has an active subscription, so a + * Couple-shared premium: a premium feature unlocks if EITHER partner has an active subscription, so a * single subscription covers the couple. Combines the current user's [EntitlementChecker.isPremium] * with a live read of the partner's `users/{partnerId}/entitlements/premium` doc (same active/expiry * rule as [FirestoreEntitlementChecker]). Reactive: flips the moment either side subscribes/expires. + * + * Exposes [isPremium]/[hasPremium] with the SAME shape as [EntitlementChecker] so it's a drop-in for + * any feature gate (resolves the partner internally), plus [coupleHasPremium] when the caller already + * knows the partner id. */ @Singleton class CouplePremiumChecker @Inject constructor( private val entitlementChecker: EntitlementChecker, - private val firestore: FirebaseFirestore + private val firestore: FirebaseFirestore, + private val coupleRepository: CoupleRepository ) { + /** Couple-shared premium for the current user (resolves the partner internally). Drop-in for EntitlementChecker. */ + fun isPremium(): Flow = flow { + val uid = FirebaseAuth.getInstance().currentUser?.uid + val partnerId = uid?.let { + runCatching { coupleRepository.getCoupleForUser(it)?.userIds?.firstOrNull { id -> id != it } }.getOrNull() + } + emitAll(coupleHasPremium(partnerId)) + } + + /** One-shot couple-shared premium check. Drop-in for EntitlementChecker.hasPremium. */ + suspend fun hasPremium(): Boolean = isPremium().first() + fun coupleHasPremium(partnerId: String?): Flow = if (partnerId.isNullOrBlank()) entitlementChecker.isPremium() else combine(entitlementChecker.isPremium(), observePartnerPremium(partnerId)) { mine, theirs -> diff --git a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt index ae31b6cf..1016e4a1 100644 --- a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt +++ b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt @@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.closer.core.billing.EntitlementChecker +import app.closer.core.billing.CouplePremiumChecker import app.closer.core.navigation.AppRoute import app.closer.data.challenges.ChallengesCatalog import app.closer.data.remote.FirestoreChallengeDataSource @@ -105,7 +105,7 @@ class ConnectionChallengesViewModel @Inject constructor( private val authRepository: AuthRepository, private val coupleRepository: CoupleRepository, private val challengeDataSource: FirestoreChallengeDataSource, - private val entitlementChecker: EntitlementChecker + private val premiumChecker: CouplePremiumChecker ) : ViewModel() { private val _uiState = MutableStateFlow(ChallengesUiState()) @@ -114,7 +114,7 @@ class ConnectionChallengesViewModel @Inject constructor( private var progressJob: Job? = null init { - entitlementChecker.isPremium() + premiumChecker.isPremium() .onEach { premium -> _uiState.update { it.copy(hasPremium = premium) } } .launchIn(viewModelScope) load() 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 8eba5a66..972c824d 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -58,7 +58,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.closer.core.billing.EntitlementChecker +import app.closer.core.billing.CouplePremiumChecker import app.closer.core.navigation.AppRoute import app.closer.data.remote.FirestoreDesireSyncDataSource import android.content.Context @@ -123,7 +123,7 @@ class DesireSyncViewModel @Inject constructor( private val repository: QuestionRepository, private val gameSessionManager: GameSessionManager, private val dataSource: FirestoreDesireSyncDataSource, - private val entitlementChecker: EntitlementChecker + private val premiumChecker: CouplePremiumChecker ) : ViewModel() { private val _uiState = MutableStateFlow(DesireSyncUiState()) @@ -144,7 +144,7 @@ class DesireSyncViewModel @Inject constructor( private fun load() { viewModelScope.launch { - if (!entitlementChecker.hasPremium()) { + if (!premiumChecker.hasPremium()) { _uiState.update { it.copy(navigateTo = AppRoute.PAYWALL) } return@launch } diff --git a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt index 715959aa..72a12025 100644 --- a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt +++ b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt @@ -70,7 +70,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.closer.core.billing.EntitlementChecker +import app.closer.core.billing.CouplePremiumChecker import app.closer.core.navigation.AppRoute import app.closer.data.remote.FirestoreCapsuleDataSource import app.closer.domain.model.TimeCapsule @@ -140,7 +140,7 @@ class MemoryLaneViewModel @Inject constructor( private val authRepository: AuthRepository, private val coupleRepository: CoupleRepository, private val capsuleDataSource: FirestoreCapsuleDataSource, - private val entitlementChecker: EntitlementChecker + private val premiumChecker: CouplePremiumChecker ) : ViewModel() { private val _uiState = MutableStateFlow(MemoryLaneUiState()) @@ -152,7 +152,7 @@ class MemoryLaneViewModel @Inject constructor( private fun load() { viewModelScope.launch { - val hasPremium = entitlementChecker.hasPremium() + val hasPremium = premiumChecker.hasPremium() if (!hasPremium) { _uiState.update { it.copy(phase = MemoryLanePhase.LIST, hasPremium = false) } return@launch diff --git a/app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt b/app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt index 9aee24d5..9393dea0 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt @@ -2,7 +2,7 @@ package app.closer.ui.play import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.closer.core.billing.EntitlementChecker +import app.closer.core.billing.CouplePremiumChecker import app.closer.domain.usecase.GameSessionManager import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -15,10 +15,10 @@ import kotlinx.coroutines.launch @HiltViewModel class PlayHubViewModel @Inject constructor( - entitlementChecker: EntitlementChecker, + premiumChecker: CouplePremiumChecker, private val gameSessionManager: GameSessionManager ) : ViewModel() { - val hasPremium: StateFlow = entitlementChecker.isPremium() + val hasPremium: StateFlow = premiumChecker.isPremium() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) // Default true so paired users never see an invite redirect flash while this loads. diff --git a/app/src/main/java/app/closer/ui/questions/QuestionPackLibraryViewModel.kt b/app/src/main/java/app/closer/ui/questions/QuestionPackLibraryViewModel.kt index 0f54c9b3..d1cffc3f 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionPackLibraryViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionPackLibraryViewModel.kt @@ -2,7 +2,7 @@ package app.closer.ui.questions import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.closer.core.billing.EntitlementChecker +import app.closer.core.billing.CouplePremiumChecker import app.closer.domain.model.QuestionCategory import app.closer.domain.repository.QuestionRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -29,7 +29,7 @@ data class QuestionPackLibraryUiState( @HiltViewModel class QuestionPackLibraryViewModel @Inject constructor( private val repository: QuestionRepository, - private val entitlementChecker: EntitlementChecker + private val premiumChecker: CouplePremiumChecker ) : ViewModel() { private val _uiState = MutableStateFlow(QuestionPackLibraryUiState()) @@ -43,7 +43,7 @@ class QuestionPackLibraryViewModel @Inject constructor( viewModelScope.launch { _uiState.value = QuestionPackLibraryUiState(isLoading = true) try { - val hasPremium = entitlementChecker.isPremium().first() + val hasPremium = premiumChecker.isPremium().first() val packs = repository.getCategories().map { category -> val isPremium = category.access == "premium" QuestionPackItem( diff --git a/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt b/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt index 5ce99600..2f1e6bf0 100644 --- a/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/CategoryPickerViewModel.kt @@ -2,7 +2,7 @@ package app.closer.ui.wheel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.closer.core.billing.EntitlementChecker +import app.closer.core.billing.CouplePremiumChecker import app.closer.core.navigation.AppRoute import app.closer.domain.model.GameType import app.closer.domain.model.QuestionCategory @@ -33,7 +33,7 @@ data class CategoryPickerUiState( @HiltViewModel class CategoryPickerViewModel @Inject constructor( private val repository: QuestionRepository, - private val entitlementChecker: EntitlementChecker, + private val premiumChecker: CouplePremiumChecker, private val gameSessionManager: GameSessionManager ) : ViewModel() { @@ -78,7 +78,7 @@ class CategoryPickerViewModel @Inject constructor( // checkActiveSession isn't clobbered by the category load finishing. _uiState.update { it.copy(isLoading = true, error = null) } try { - val hasPremium = entitlementChecker.isPremium().first() + val hasPremium = premiumChecker.isPremium().first() val items = repository.getCategories().map { category -> CategoryPickerItem( category = category, 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 6cc11eee..0b7ee33e 100644 --- a/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/SpinWheelViewModel.kt @@ -4,7 +4,7 @@ import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.closer.core.billing.EntitlementChecker +import app.closer.core.billing.CouplePremiumChecker import app.closer.core.navigation.AppRoute import app.closer.domain.model.GameType import app.closer.domain.model.QuestionCategory @@ -38,7 +38,7 @@ class SpinWheelViewModel @Inject constructor( private val repository: QuestionRepository, private val sessionStore: LocalWheelSessionStore, private val gameSessionManager: GameSessionManager, - private val entitlementChecker: EntitlementChecker + private val premiumChecker: CouplePremiumChecker ) : ViewModel() { private val categoryId: String = savedStateHandle["categoryId"] ?: "" @@ -56,7 +56,7 @@ class SpinWheelViewModel @Inject constructor( private fun loadPremiumStatus() { viewModelScope.launch { - val hasPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false) + val hasPremium = runCatching { premiumChecker.isPremium().first() }.getOrDefault(false) _uiState.update { it.copy(hasPremium = hasPremium) } } } diff --git a/app/src/main/java/app/closer/ui/wheel/WheelHistoryViewModel.kt b/app/src/main/java/app/closer/ui/wheel/WheelHistoryViewModel.kt index 7b1eb5b7..4b051a18 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelHistoryViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelHistoryViewModel.kt @@ -2,7 +2,7 @@ package app.closer.ui.wheel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.closer.core.billing.EntitlementChecker +import app.closer.core.billing.CouplePremiumChecker import app.closer.data.challenges.ChallengesCatalog import app.closer.data.remote.FirestoreCapsuleDataSource import app.closer.data.remote.FirestoreChallengeDataSource @@ -45,14 +45,14 @@ class GameHistoryViewModel @Inject constructor( private val coupleRepository: CoupleRepository, private val challengeDataSource: FirestoreChallengeDataSource, private val capsuleDataSource: FirestoreCapsuleDataSource, - entitlementChecker: EntitlementChecker + premiumChecker: CouplePremiumChecker ) : ViewModel() { private val _uiState = MutableStateFlow(GameHistoryUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { - entitlementChecker.isPremium() + premiumChecker.isPremium() .onEach { premium -> _uiState.update { it.copy(hasPremium = premium) } if (premium) load()