feat(paywall): question pack library, entitlement checker, home screen wiring

This commit is contained in:
null 2026-06-16 00:50:13 -05:00
parent 29d512c679
commit 577d39ea11
8 changed files with 267 additions and 42 deletions

View File

@ -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
}

View File

@ -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<List<QuestionAnswer>> =

View File

@ -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
}

View File

@ -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",
text = "Tonights connection",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F)
color = Color(0xFF27211F),
modifier = Modifier.weight(1f)
)
if (streakCount > 0) {
HomePill("$streakCount day streak")
}
}
Text(
text = "A quiet home for todays 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 todays 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(

View File

@ -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<HomeCategorySummary> = 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) {

View File

@ -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

View File

@ -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
)
}
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())
}

View File

@ -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)