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.QuestionReaction
import com.couplesconnect.app.domain.model.QuestionThread import com.couplesconnect.app.domain.model.QuestionThread
import com.couplesconnect.app.domain.model.QuestionThreadStatus import com.couplesconnect.app.domain.model.QuestionThreadStatus
import com.couplesconnect.app.domain.repository.CoupleRepository
import com.couplesconnect.app.domain.repository.QuestionThreadRepository import com.couplesconnect.app.domain.repository.QuestionThreadRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
@ -13,7 +14,8 @@ import javax.inject.Singleton
@Singleton @Singleton
class QuestionThreadRepositoryImpl @Inject constructor( class QuestionThreadRepositoryImpl @Inject constructor(
private val dataSource: FirestoreQuestionThreadDataSource private val dataSource: FirestoreQuestionThreadDataSource,
private val coupleRepository: CoupleRepository
) : QuestionThreadRepository { ) : QuestionThreadRepository {
override suspend fun findOrCreateThreadId( override suspend fun findOrCreateThreadId(
@ -32,14 +34,18 @@ class QuestionThreadRepositoryImpl @Inject constructor(
userId: String, userId: String,
answer: QuestionAnswer answer: QuestionAnswer
) { ) {
val countBefore = dataSource.getAnswerCount(coupleId, threadId)
dataSource.submitAnswer(coupleId, threadId, userId, answer) dataSource.submitAnswer(coupleId, threadId, userId, answer)
val count = dataSource.getAnswerCount(coupleId, threadId) val countAfter = dataSource.getAnswerCount(coupleId, threadId)
val newStatus = when { val newStatus = when {
count >= 2 -> QuestionThreadStatus.REVEALED countAfter >= 2 -> QuestionThreadStatus.REVEALED
count == 1 -> QuestionThreadStatus.ANSWERED_BY_ONE countAfter == 1 -> QuestionThreadStatus.ANSWERED_BY_ONE
else -> QuestionThreadStatus.NOT_STARTED else -> QuestionThreadStatus.NOT_STARTED
} }
dataSource.updateThreadStatus(coupleId, threadId, newStatus) 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>> = override fun observeAnswers(coupleId: String, threadId: String): Flow<List<QuestionAnswer>> =

View File

@ -1,5 +1,7 @@
package com.couplesconnect.app.di 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.CoupleRepositoryImpl
import com.couplesconnect.app.data.repository.FirebaseAuthRepositoryImpl import com.couplesconnect.app.data.repository.FirebaseAuthRepositoryImpl
import com.couplesconnect.app.data.repository.InviteRepositoryImpl import com.couplesconnect.app.data.repository.InviteRepositoryImpl
@ -44,4 +46,7 @@ abstract class RepositoryModule {
@Binds @Singleton @Binds @Singleton
abstract fun bindLocalAnswerRepository(impl: SharedPreferencesLocalAnswerRepository): LocalAnswerRepository 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), .padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp) verticalArrangement = Arrangement.spacedBy(18.dp)
) { ) {
HomeHeader() HomeHeader(
partnerName = state.partnerName,
streakCount = state.streakCount
)
when { when {
state.isLoading -> LoadingHomeCard() state.isLoading -> LoadingHomeCard()
@ -122,15 +125,31 @@ private fun HomeContent(
} }
@Composable @Composable
private fun HomeHeader() { private fun HomeHeader(
partnerName: String?,
streakCount: Int
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Tonights 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(
text = "Tonight's connection", text = if (partnerName != null)
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold), "Connected with $partnerName. Keep the conversation going."
color = Color(0xFF27211F) else
) "A quiet home for todays prompt, saved reflections, and the next conversation worth opening.",
Text(
text = "A quiet home for todays prompt, saved reflections, and the next conversation worth opening.",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642) color = Color(0xFF4E4642)
) )
@ -468,6 +487,8 @@ fun HomeScreenPreview() {
depthLevel = 2 depthLevel = 2
), ),
answerStats = HomeAnswerStats(total = 4, revealed = 2, private = 2), answerStats = HomeAnswerStats(total = 4, revealed = 2, private = 2),
partnerName = "Jordan",
streakCount = 7,
categories = listOf( categories = listOf(
HomeCategorySummary( HomeCategorySummary(
category = QuestionCategory( 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.LocalAnswer
import com.couplesconnect.app.domain.model.Question import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.QuestionCategory 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.LocalAnswerRepository
import com.couplesconnect.app.domain.repository.QuestionRepository import com.couplesconnect.app.domain.repository.QuestionRepository
import com.couplesconnect.app.domain.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -32,13 +35,18 @@ data class HomeUiState(
val error: String? = null, val error: String? = null,
val dailyQuestion: Question? = null, val dailyQuestion: Question? = null,
val categories: List<HomeCategorySummary> = emptyList(), val categories: List<HomeCategorySummary> = emptyList(),
val answerStats: HomeAnswerStats = HomeAnswerStats() val answerStats: HomeAnswerStats = HomeAnswerStats(),
val partnerName: String? = null,
val streakCount: Int = 0
) )
@HiltViewModel @HiltViewModel
class HomeViewModel @Inject constructor( class HomeViewModel @Inject constructor(
private val questionRepository: QuestionRepository, 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() { ) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
@ -62,11 +70,18 @@ class HomeViewModel @Inject constructor(
questionCount = questionRepository.getQuestionCountByCategory(category.id) 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 { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
dailyQuestion = dailyQuestion, dailyQuestion = dailyQuestion,
categories = categories categories = categories,
partnerName = partnerName,
streakCount = couple?.streakCount ?: 0
) )
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@ -1,32 +1,168 @@
package com.couplesconnect.app.ui.paywall 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.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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute import androidx.compose.ui.unit.dp
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen 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 @Composable
fun PaywallScreen( fun PaywallScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {}
) { ) {
PlaceholderScreen( Box(
title = "Deeper practice", modifier = Modifier
section = "Paywall", .fillMaxSize()
description = "A premium surface for expanded packs, rituals, and advanced couple reflection tools.", .background(
route = AppRoute.PAYWALL, Brush.linearGradient(
onNavigate = onNavigate, listOf(Color(0xFFFFFBFA), Color(0xFFF5EEE8), Color(0xFFEAF0F4)),
accent = Color(0xFFF2A65A), start = Offset.Zero,
primaryAction = PlaceholderAction("Subscription", AppRoute.SUBSCRIPTION), end = Offset.Infinite
secondaryAction = PlaceholderAction("Home", AppRoute.HOME), )
chips = listOf("Premium", "Deeper packs", "Upgrade path"), )
details = listOf( ) {
"Plan comparison can stay clear and generous", Column(
"Deeper question packs can be framed with care", modifier = Modifier
"Subscription management has its own place" .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 @Preview

View File

@ -10,14 +10,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -112,7 +116,9 @@ private fun QuestionPackLibraryContent(
items(state.packs, key = { it.category.id }) { item -> items(state.packs, key = { it.category.id }) { item ->
QuestionPackCard( QuestionPackCard(
item = item, item = item,
onClick = { onPackSelected(item) } onClick = {
if (item.isLocked) onPaywall() else onPackSelected(item)
}
) )
} }
item { item {
@ -127,7 +133,7 @@ private fun QuestionPackLibraryContent(
contentColor = Color.White contentColor = Color.White
) )
) { ) {
Text("Preview premium path") Text("Unlock all packs")
} }
} }
} }
@ -141,11 +147,16 @@ private fun QuestionPackCard(
item: QuestionPackItem, item: QuestionPackItem,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val containerColor = if (item.isLocked)
Color(0xFFF5F0EC).copy(alpha = 0.84f)
else
Color.White.copy(alpha = 0.84f)
Card( Card(
onClick = onClick, onClick = onClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp), 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) elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) { ) {
Column( Column(
@ -164,7 +175,7 @@ private fun QuestionPackCard(
Text( Text(
text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() }, text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() },
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = Color(0xFF27211F), color = if (item.isLocked) Color(0xFF9E9693) else Color(0xFF27211F),
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@ -177,9 +188,19 @@ private fun QuestionPackCard(
overflow = TextOverflow.Ellipsis 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)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (item.isPremium) PackPill("Premium")
PackPill(item.category.access.displayCategoryName()) PackPill(item.category.access.displayCategoryName())
PackPill(item.category.iconName.ifBlank { "question" }.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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.core.billing.EntitlementChecker
import com.couplesconnect.app.domain.model.QuestionCategory import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.domain.repository.QuestionRepository import com.couplesconnect.app.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -13,7 +14,9 @@ import kotlinx.coroutines.launch
data class QuestionPackItem( data class QuestionPackItem(
val category: QuestionCategory, val category: QuestionCategory,
val questionCount: Int val questionCount: Int,
val isPremium: Boolean = false,
val isLocked: Boolean = false
) )
data class QuestionPackLibraryUiState( data class QuestionPackLibraryUiState(
@ -24,7 +27,8 @@ data class QuestionPackLibraryUiState(
@HiltViewModel @HiltViewModel
class QuestionPackLibraryViewModel @Inject constructor( class QuestionPackLibraryViewModel @Inject constructor(
private val repository: QuestionRepository private val repository: QuestionRepository,
private val entitlementChecker: EntitlementChecker
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(QuestionPackLibraryUiState()) private val _uiState = MutableStateFlow(QuestionPackLibraryUiState())
@ -38,10 +42,14 @@ class QuestionPackLibraryViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_uiState.value = QuestionPackLibraryUiState(isLoading = true) _uiState.value = QuestionPackLibraryUiState(isLoading = true)
try { try {
val hasPremium = entitlementChecker.hasPremium
val packs = repository.getCategories().map { category -> val packs = repository.getCategories().map { category ->
val isPremium = category.access == "premium"
QuestionPackItem( QuestionPackItem(
category = category, category = category,
questionCount = repository.getQuestionCountByCategory(category.id) questionCount = repository.getQuestionCountByCategory(category.id),
isPremium = isPremium,
isLocked = isPremium && !hasPremium
) )
} }
_uiState.value = QuestionPackLibraryUiState(isLoading = false, packs = packs) _uiState.value = QuestionPackLibraryUiState(isLoading = false, packs = packs)