From 577d39ea1116b4bd04a800d63d4b95bd2f28714c Mon Sep 17 00:00:00 2001 From: null Date: Tue, 16 Jun 2026 00:50:13 -0500 Subject: [PATCH] feat(paywall): question pack library, entitlement checker, home screen wiring --- .../app/core/billing/EntitlementChecker.kt | 13 ++ .../QuestionThreadRepositoryImpl.kt | 14 +- .../couplesconnect/app/di/RepositoryModule.kt | 5 + .../couplesconnect/app/ui/home/HomeScreen.kt | 37 +++- .../app/ui/home/HomeViewModel.kt | 21 ++- .../app/ui/paywall/PaywallScreen.kt | 174 ++++++++++++++++-- .../ui/questions/QuestionPackLibraryScreen.kt | 31 +++- .../questions/QuestionPackLibraryViewModel.kt | 14 +- 8 files changed, 267 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/couplesconnect/app/core/billing/EntitlementChecker.kt diff --git a/app/src/main/java/com/couplesconnect/app/core/billing/EntitlementChecker.kt b/app/src/main/java/com/couplesconnect/app/core/billing/EntitlementChecker.kt new file mode 100644 index 00000000..1f18c4b3 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/core/billing/EntitlementChecker.kt @@ -0,0 +1,13 @@ +package com.couplesconnect.app.core.billing + +import javax.inject.Inject +import javax.inject.Singleton + +interface EntitlementChecker { + val hasPremium: Boolean +} + +@Singleton +class FakeEntitlementChecker @Inject constructor() : EntitlementChecker { + override val hasPremium: Boolean = false +} diff --git a/app/src/main/java/com/couplesconnect/app/data/repository/QuestionThreadRepositoryImpl.kt b/app/src/main/java/com/couplesconnect/app/data/repository/QuestionThreadRepositoryImpl.kt index aeb38f10..82dce628 100644 --- a/app/src/main/java/com/couplesconnect/app/data/repository/QuestionThreadRepositoryImpl.kt +++ b/app/src/main/java/com/couplesconnect/app/data/repository/QuestionThreadRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.couplesconnect.app.domain.model.QuestionMessage import com.couplesconnect.app.domain.model.QuestionReaction import com.couplesconnect.app.domain.model.QuestionThread import com.couplesconnect.app.domain.model.QuestionThreadStatus +import com.couplesconnect.app.domain.repository.CoupleRepository import com.couplesconnect.app.domain.repository.QuestionThreadRepository import kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -13,7 +14,8 @@ import javax.inject.Singleton @Singleton class QuestionThreadRepositoryImpl @Inject constructor( - private val dataSource: FirestoreQuestionThreadDataSource + private val dataSource: FirestoreQuestionThreadDataSource, + private val coupleRepository: CoupleRepository ) : QuestionThreadRepository { override suspend fun findOrCreateThreadId( @@ -32,14 +34,18 @@ class QuestionThreadRepositoryImpl @Inject constructor( userId: String, answer: QuestionAnswer ) { + val countBefore = dataSource.getAnswerCount(coupleId, threadId) dataSource.submitAnswer(coupleId, threadId, userId, answer) - val count = dataSource.getAnswerCount(coupleId, threadId) + val countAfter = dataSource.getAnswerCount(coupleId, threadId) val newStatus = when { - count >= 2 -> QuestionThreadStatus.REVEALED - count == 1 -> QuestionThreadStatus.ANSWERED_BY_ONE + countAfter >= 2 -> QuestionThreadStatus.REVEALED + countAfter == 1 -> QuestionThreadStatus.ANSWERED_BY_ONE else -> QuestionThreadStatus.NOT_STARTED } dataSource.updateThreadStatus(coupleId, threadId, newStatus) + if (countBefore < 2 && newStatus == QuestionThreadStatus.REVEALED) { + runCatching { coupleRepository.updateStreak(coupleId) } + } } override fun observeAnswers(coupleId: String, threadId: String): Flow> = diff --git a/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt b/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt index 520330b3..519752f1 100644 --- a/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt +++ b/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt @@ -1,5 +1,7 @@ package com.couplesconnect.app.di +import com.couplesconnect.app.core.billing.EntitlementChecker +import com.couplesconnect.app.core.billing.FakeEntitlementChecker import com.couplesconnect.app.data.repository.CoupleRepositoryImpl import com.couplesconnect.app.data.repository.FirebaseAuthRepositoryImpl import com.couplesconnect.app.data.repository.InviteRepositoryImpl @@ -44,4 +46,7 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindLocalAnswerRepository(impl: SharedPreferencesLocalAnswerRepository): LocalAnswerRepository + + @Binds @Singleton + abstract fun bindEntitlementChecker(impl: FakeEntitlementChecker): EntitlementChecker } diff --git a/app/src/main/java/com/couplesconnect/app/ui/home/HomeScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/home/HomeScreen.kt index 39347917..ea36f773 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/home/HomeScreen.kt @@ -90,7 +90,10 @@ private fun HomeContent( .padding(horizontal = 20.dp, vertical = 20.dp), verticalArrangement = Arrangement.spacedBy(18.dp) ) { - HomeHeader() + HomeHeader( + partnerName = state.partnerName, + streakCount = state.streakCount + ) when { state.isLoading -> LoadingHomeCard() @@ -122,15 +125,31 @@ private fun HomeContent( } @Composable -private fun HomeHeader() { +private fun HomeHeader( + partnerName: String?, + streakCount: Int +) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Tonight’s connection", + style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold), + color = Color(0xFF27211F), + modifier = Modifier.weight(1f) + ) + if (streakCount > 0) { + HomePill("$streakCount day streak") + } + } Text( - text = "Tonight's connection", - style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold), - color = Color(0xFF27211F) - ) - Text( - text = "A quiet home for today’s prompt, saved reflections, and the next conversation worth opening.", + text = if (partnerName != null) + "Connected with $partnerName. Keep the conversation going." + else + "A quiet home for today’s prompt, saved reflections, and the next conversation worth opening.", style = MaterialTheme.typography.bodyLarge, color = Color(0xFF4E4642) ) @@ -468,6 +487,8 @@ fun HomeScreenPreview() { depthLevel = 2 ), answerStats = HomeAnswerStats(total = 4, revealed = 2, private = 2), + partnerName = "Jordan", + streakCount = 7, categories = listOf( HomeCategorySummary( category = QuestionCategory( diff --git a/app/src/main/java/com/couplesconnect/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/home/HomeViewModel.kt index f11459f6..bc03ee93 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/home/HomeViewModel.kt @@ -5,8 +5,11 @@ import androidx.lifecycle.viewModelScope import com.couplesconnect.app.domain.model.LocalAnswer import com.couplesconnect.app.domain.model.Question import com.couplesconnect.app.domain.model.QuestionCategory +import com.couplesconnect.app.domain.repository.AuthRepository +import com.couplesconnect.app.domain.repository.CoupleRepository import com.couplesconnect.app.domain.repository.LocalAnswerRepository import com.couplesconnect.app.domain.repository.QuestionRepository +import com.couplesconnect.app.domain.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -32,13 +35,18 @@ data class HomeUiState( val error: String? = null, val dailyQuestion: Question? = null, val categories: List = emptyList(), - val answerStats: HomeAnswerStats = HomeAnswerStats() + val answerStats: HomeAnswerStats = HomeAnswerStats(), + val partnerName: String? = null, + val streakCount: Int = 0 ) @HiltViewModel class HomeViewModel @Inject constructor( private val questionRepository: QuestionRepository, - private val localAnswerRepository: LocalAnswerRepository + private val localAnswerRepository: LocalAnswerRepository, + private val authRepository: AuthRepository, + private val coupleRepository: CoupleRepository, + private val userRepository: UserRepository ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) @@ -62,11 +70,18 @@ class HomeViewModel @Inject constructor( questionCount = questionRepository.getQuestionCountByCategory(category.id) ) } + val uid = authRepository.currentUserId + val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() } + val partnerName = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId -> + runCatching { userRepository.getUser(partnerId)?.displayName }.getOrNull() + } _uiState.update { it.copy( isLoading = false, dailyQuestion = dailyQuestion, - categories = categories + categories = categories, + partnerName = partnerName, + streakCount = couple?.streakCount ?: 0 ) } } catch (e: Exception) { diff --git a/app/src/main/java/com/couplesconnect/app/ui/paywall/PaywallScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/paywall/PaywallScreen.kt index f4301d11..8cf384b3 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/paywall/PaywallScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/paywall/PaywallScreen.kt @@ -1,32 +1,168 @@ package com.couplesconnect.app.ui.paywall +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.Row +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.couplesconnect.app.core.navigation.AppRoute -import com.couplesconnect.app.ui.components.PlaceholderAction -import com.couplesconnect.app.ui.components.PlaceholderScreen +import androidx.compose.ui.unit.dp + +private val BENEFITS = listOf( + "Unlock every question pack — 5500+ prompts", + "Deeper intimacy, trust, and conflict tracks", + "Priority access to new seasonal packs", + "Support independent couple-focused development" +) @Composable fun PaywallScreen( onNavigate: (String) -> Unit = {} ) { - PlaceholderScreen( - title = "Deeper practice", - section = "Paywall", - description = "A premium surface for expanded packs, rituals, and advanced couple reflection tools.", - route = AppRoute.PAYWALL, - onNavigate = onNavigate, - accent = Color(0xFFF2A65A), - primaryAction = PlaceholderAction("Subscription", AppRoute.SUBSCRIPTION), - secondaryAction = PlaceholderAction("Home", AppRoute.HOME), - chips = listOf("Premium", "Deeper packs", "Upgrade path"), - details = listOf( - "Plan comparison can stay clear and generous", - "Deeper question packs can be framed with care", - "Subscription management has its own place" - ) - ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + listOf(Color(0xFFFFFBFA), Color(0xFFF5EEE8), Color(0xFFEAF0F4)), + start = Offset.Zero, + end = Offset.Infinite + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Go deeper together", + style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold), + color = Color(0xFF27211F), + textAlign = TextAlign.Center + ) + Text( + text = "One subscription. Every question pack we've built for couples — and everything we build next.", + style = MaterialTheme.typography.bodyLarge, + color = Color(0xFF4E4642), + textAlign = TextAlign.Center + ) + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.88f)), + elevation = CardDefaults.cardElevation(defaultElevation = 10.dp) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "What's included", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = Color(0xFF27211F) + ) + BENEFITS.forEach { benefit -> + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color(0xFFE07A5F), + modifier = Modifier.size(18.dp) + ) + Text( + text = benefit, + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF3E3734) + ) + } + } + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFE07A5F)), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) + ) { + Column( + modifier = Modifier.padding(22.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = "Coming soon", + style = MaterialTheme.typography.labelLarge, + color = Color.White.copy(alpha = 0.8f) + ) + Text( + text = "In-app purchase launching with the next build.", + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + textAlign = TextAlign.Center + ) + } + } + + Button( + onClick = { onNavigate("back") }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F)) + ) { + Text("Subscribe (coming soon)", color = Color.White) + } + + TextButton(onClick = { onNavigate("back") }) { + Text( + text = "Not now", + color = Color(0xFF9E9693) + ) + } + } + } } @Preview diff --git a/app/src/main/java/com/couplesconnect/app/ui/questions/QuestionPackLibraryScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/questions/QuestionPackLibraryScreen.kt index 17bb54b3..9a7f7140 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/questions/QuestionPackLibraryScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/questions/QuestionPackLibraryScreen.kt @@ -10,14 +10,18 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -112,7 +116,9 @@ private fun QuestionPackLibraryContent( items(state.packs, key = { it.category.id }) { item -> QuestionPackCard( item = item, - onClick = { onPackSelected(item) } + onClick = { + if (item.isLocked) onPaywall() else onPackSelected(item) + } ) } item { @@ -127,7 +133,7 @@ private fun QuestionPackLibraryContent( contentColor = Color.White ) ) { - Text("Preview premium path") + Text("Unlock all packs") } } } @@ -141,11 +147,16 @@ private fun QuestionPackCard( item: QuestionPackItem, onClick: () -> Unit ) { + val containerColor = if (item.isLocked) + Color(0xFFF5F0EC).copy(alpha = 0.84f) + else + Color.White.copy(alpha = 0.84f) + Card( onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(26.dp), - colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.84f)), + colors = CardDefaults.cardColors(containerColor = containerColor), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) ) { Column( @@ -164,7 +175,7 @@ private fun QuestionPackCard( Text( text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() }, style = MaterialTheme.typography.titleLarge, - color = Color(0xFF27211F), + color = if (item.isLocked) Color(0xFF9E9693) else Color(0xFF27211F), fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis @@ -177,9 +188,19 @@ private fun QuestionPackCard( overflow = TextOverflow.Ellipsis ) } - PackPill("${item.questionCount} prompts") + if (item.isLocked) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "Premium", + tint = Color(0xFFB0A9A6), + modifier = Modifier.size(20.dp) + ) + } else { + PackPill("${item.questionCount} prompts") + } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (item.isPremium) PackPill("Premium") PackPill(item.category.access.displayCategoryName()) PackPill(item.category.iconName.ifBlank { "question" }.displayCategoryName()) } diff --git a/app/src/main/java/com/couplesconnect/app/ui/questions/QuestionPackLibraryViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/questions/QuestionPackLibraryViewModel.kt index 9f2127e6..8f4f1d02 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/questions/QuestionPackLibraryViewModel.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/questions/QuestionPackLibraryViewModel.kt @@ -2,6 +2,7 @@ package com.couplesconnect.app.ui.questions import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.couplesconnect.app.core.billing.EntitlementChecker import com.couplesconnect.app.domain.model.QuestionCategory import com.couplesconnect.app.domain.repository.QuestionRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -13,7 +14,9 @@ import kotlinx.coroutines.launch data class QuestionPackItem( val category: QuestionCategory, - val questionCount: Int + val questionCount: Int, + val isPremium: Boolean = false, + val isLocked: Boolean = false ) data class QuestionPackLibraryUiState( @@ -24,7 +27,8 @@ data class QuestionPackLibraryUiState( @HiltViewModel class QuestionPackLibraryViewModel @Inject constructor( - private val repository: QuestionRepository + private val repository: QuestionRepository, + private val entitlementChecker: EntitlementChecker ) : ViewModel() { private val _uiState = MutableStateFlow(QuestionPackLibraryUiState()) @@ -38,10 +42,14 @@ class QuestionPackLibraryViewModel @Inject constructor( viewModelScope.launch { _uiState.value = QuestionPackLibraryUiState(isLoading = true) try { + val hasPremium = entitlementChecker.hasPremium val packs = repository.getCategories().map { category -> + val isPremium = category.access == "premium" QuestionPackItem( category = category, - questionCount = repository.getQuestionCountByCategory(category.id) + questionCount = repository.getQuestionCountByCategory(category.id), + isPremium = isPremium, + isLocked = isPremium && !hasPremium ) } _uiState.value = QuestionPackLibraryUiState(isLoading = false, packs = packs)