fix(premium): couple-shared premium everywhere (A-001)
Route all feature gates (Play hub, Desire Sync, Memory Lane, Connection Challenges, Question Packs, wheel category/spin/history) through CouplePremiumChecker instead of per-user EntitlementChecker. CouplePremiumChecker now exposes isPremium()/hasPremium() that resolve the partner internally (self OR partner premium). Verified live: Sam premium → QA enters Desire Sync; both free → QA → paywall. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c54ceb16c3
commit
e8892a9669
|
|
@ -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<Boolean> = 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<Boolean> =
|
||||
if (partnerId.isNullOrBlank()) entitlementChecker.isPremium()
|
||||
else combine(entitlementChecker.isPremium(), observePartnerPremium(partnerId)) { mine, theirs ->
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Boolean> = entitlementChecker.isPremium()
|
||||
val hasPremium: StateFlow<Boolean> = premiumChecker.isPremium()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
// Default true so paired users never see an invite redirect flash while this loads.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GameHistoryUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
entitlementChecker.isPremium()
|
||||
premiumChecker.isPremium()
|
||||
.onEach { premium ->
|
||||
_uiState.update { it.copy(hasPremium = premium) }
|
||||
if (premium) load()
|
||||
|
|
|
|||
Loading…
Reference in New Issue