diff --git a/app/src/main/java/app/closer/core/billing/CouplePremiumChecker.kt b/app/src/main/java/app/closer/core/billing/CouplePremiumChecker.kt new file mode 100644 index 00000000..c4396549 --- /dev/null +++ b/app/src/main/java/app/closer/core/billing/CouplePremiumChecker.kt @@ -0,0 +1,43 @@ +package app.closer.core.billing + +import app.closer.data.remote.FirestoreCollections +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 javax.inject.Inject +import javax.inject.Singleton + +/** + * Couple-shared premium: media/reactions unlock 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. + */ +@Singleton +class CouplePremiumChecker @Inject constructor( + private val entitlementChecker: EntitlementChecker, + private val firestore: FirebaseFirestore +) { + fun coupleHasPremium(partnerId: String?): Flow = + if (partnerId.isNullOrBlank()) entitlementChecker.isPremium() + else combine(entitlementChecker.isPremium(), observePartnerPremium(partnerId)) { mine, theirs -> + mine || theirs + }.distinctUntilChanged() + + private fun observePartnerPremium(partnerId: String): Flow = callbackFlow { + val ref = firestore.collection(FirestoreCollections.USERS).document(partnerId) + .collection(FirestoreCollections.Users.ENTITLEMENTS) + .document(FirestoreCollections.Users.ENTITLEMENT_PREMIUM_DOC) + val listener = ref.addSnapshotListener { snap, err -> + if (err != null || snap == null || !snap.exists()) { trySend(false); return@addSnapshotListener } + val premium = snap.getBoolean("premium") ?: false + val expiresAt = snap.getTimestamp("expiresAt") + val active = premium && (expiresAt == null || expiresAt.seconds > System.currentTimeMillis() / 1000) + trySend(active) + } + awaitClose { listener.remove() } + } +} diff --git a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt index 75baff0f..faf5ef98 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import app.closer.core.navigation.AppRoute import app.closer.ui.messages.components.ChatComposer import app.closer.ui.messages.components.ChatDaySeparator import app.closer.ui.messages.components.ChatMessageRow @@ -67,6 +68,11 @@ fun ConversationScreen( val listState = rememberLazyListState() val snackbarHostState = remember { SnackbarHostState() } + val routeToPaywall: () -> Unit = { + viewModel.onMediaPaywallShown() + onNavigate(AppRoute.PAYWALL) + } + // Surface send failures as a snackbar. LaunchedEffect(Unit) { viewModel.events.collect { snackbarHostState.showSnackbar(it) } @@ -151,7 +157,9 @@ fun ConversationScreen( showAvatar = isLastInRun, showTimestamp = isLastInRun, showSeen = seen, + canReact = state.canSendMedia, onReact = { emoji -> viewModel.react(message.id, emoji) }, + onReactBlocked = routeToPaywall, onDelete = { viewModel.deleteMessage(message.id) }, loadDecryptedMedia = viewModel::loadDecryptedMedia ) @@ -181,6 +189,8 @@ fun ConversationScreen( onSend = viewModel::sendMessage, onSendImage = viewModel::sendImage, onSendVoice = viewModel::sendVoice, + canSendMedia = state.canSendMedia, + onUpgrade = routeToPaywall, modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) ) } diff --git a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt index f7b7dad0..88b8b56d 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt @@ -3,6 +3,8 @@ package app.closer.ui.messages import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.closer.core.analytics.AnalyticsTracker +import app.closer.core.billing.CouplePremiumChecker import app.closer.data.local.QuestionDao import app.closer.data.local.mapper.toQuestion import app.closer.domain.model.QuestionMessage @@ -41,6 +43,8 @@ data class ConversationUiState( val canLoadMore: Boolean = false, val partnerReadAt: Long = 0L, val partnerTyping: Boolean = false, + /** Couple-shared premium: gates SENDING media + reactions (false until confirmed). */ + val canSendMedia: Boolean = false, val messageInput: String = "", val isLoading: Boolean = true ) @@ -53,6 +57,8 @@ class ConversationViewModel @Inject constructor( private val userRepository: UserRepository, private val questionDao: QuestionDao, private val activeThreadMonitor: ActiveThreadMonitor, + private val couplePremiumChecker: CouplePremiumChecker, + private val analyticsTracker: AnalyticsTracker, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -107,6 +113,12 @@ class ConversationViewModel @Inject constructor( it.copy(title = title, partnerPhotoUrl = partner?.photoUrl, isLoading = false) } + // Couple-shared premium gate for sending media + reactions. + launch { + couplePremiumChecker.coupleHasPremium(partnerId) + .collect { premium -> _uiState.update { it.copy(canSendMedia = premium) } } + } + runCatching { repository.markRead(coupleId, conversationId, currentUserId) } launch { @@ -214,6 +226,11 @@ class ConversationViewModel @Inject constructor( } } + /** Called when a gated media/reaction action routes the user to the paywall. */ + fun onMediaPaywallShown() { + analyticsTracker.trackPaywallViewed("chat_media") + } + fun deleteMessage(messageId: String) { viewModelScope.launch { runCatching { repository.deleteMessage(coupleId, conversationId, messageId) } diff --git a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt index 8b11e34a..f7e21a9e 100644 --- a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt +++ b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Person @@ -526,6 +527,8 @@ fun ChatComposer( onSend: () -> Unit, onSendImage: (ByteArray) -> Unit, onSendVoice: (ByteArray, Long) -> Unit, + canSendMedia: Boolean = true, + onUpgrade: () -> Unit = {}, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -647,21 +650,23 @@ fun ChatComposer( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - IconButton( + ComposerMediaButton( + icon = Icons.Filled.Image, + description = "Send a photo", + locked = !canSendMedia, onClick = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, - modifier = Modifier.size(40.dp) - ) { - Icon(Icons.Filled.Image, contentDescription = "Send a photo", tint = MaterialTheme.colorScheme.primary) - } - IconButton( + onUpgrade = onUpgrade + ) + ComposerMediaButton( + icon = Icons.Filled.PhotoCamera, + description = "Take a photo", + locked = !canSendMedia, onClick = { if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA) }, - modifier = Modifier.size(40.dp) - ) { - Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary) - } + onUpgrade = onUpgrade + ) // Field that accepts GIFs / stickers / Bitmoji inserted from the keyboard (rich content). Surface( @@ -673,23 +678,26 @@ fun ChatComposer( RichContentTextField( value = value, onValueChange = onValueChange, - onImageReceived = { uri -> readAndSend(uri, compress = false) }, + // Keyboard GIF/sticker/Bitmoji: gated → route to paywall instead of sending. + onImageReceived = { uri -> if (canSendMedia) readAndSend(uri, compress = false) else onUpgrade() }, modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), hint = "Message…" ) } if (value.isBlank()) { - // Mic when there's nothing typed (tap to record a voice note). - IconButton( + // Mic when there's nothing typed (tap to record a voice note) — gated behind premium. + ComposerMediaButton( + icon = Icons.Filled.Mic, + description = "Record a voice note", + locked = !canSendMedia, + size = 48.dp, onClick = { if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) startRecording() else micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }, - modifier = Modifier.size(48.dp) - ) { - Icon(Icons.Filled.Mic, contentDescription = "Record a voice note", tint = MaterialTheme.colorScheme.primary) - } + onUpgrade = onUpgrade + ) } else { IconButton(onClick = onSend, modifier = Modifier.size(48.dp)) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = MaterialTheme.colorScheme.primary) @@ -701,6 +709,36 @@ fun ChatComposer( /** Cap voice notes at 2 minutes; recording auto-stops and sends at this length. */ private const val MAX_RECORDING_MS = 120_000L +/** A composer media button with a lock badge + paywall routing when the couple isn't premium. */ +@Composable +private fun ComposerMediaButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + locked: Boolean, + onClick: () -> Unit, + onUpgrade: () -> Unit, + size: androidx.compose.ui.unit.Dp = 40.dp +) { + Box { + IconButton(onClick = { if (locked) onUpgrade() else onClick() }, modifier = Modifier.size(size)) { + Icon( + imageVector = icon, + contentDescription = description, + tint = if (locked) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) + else MaterialTheme.colorScheme.primary + ) + } + if (locked) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(12.dp).align(Alignment.BottomEnd) + ) + } + } +} + private fun formatDuration(ms: Long): String { val totalSec = (ms / 1000).toInt() val m = totalSec / 60 diff --git a/firestore.rules b/firestore.rules index c260c96c..61457e48 100644 --- a/firestore.rules +++ b/firestore.rules @@ -176,7 +176,17 @@ service cloud.firestore { // Entitlements written server-side only (RevenueCat webhook via Admin SDK). // Client needs read access so FirestoreEntitlementChecker can observe premium state. match /entitlements/{entitlementDoc} { - allow read: if isOwner(uid); + // Owner reads their own; a paired partner may also read it so premium can be shared + // across the couple (chat media unlocks if EITHER partner is premium). + allow read: if isOwner(uid) + || ( + request.auth != null + && exists(/databases/$(database)/documents/users/$(uid)) + && get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null + && exists(/databases/$(database)/documents/users/$(request.auth.uid)) + && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId + == get(/databases/$(database)/documents/users/$(uid)).data.coupleId + ); allow write: if false; }