feat(billing): RevenueCat SDK integration, BillingRepository, PaywallScreen + ViewModel, Hilt DI, navigation route (batch 9)

This commit is contained in:
null 2026-06-17 01:22:24 -05:00
parent c9ff160bf3
commit 681cf7fb32
18 changed files with 974 additions and 240 deletions

View File

@ -18,6 +18,13 @@ android {
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
// RevenueCat API key is supplied via local.properties (RC_API_KEY) and never committed.
buildConfigField(
"String",
"RC_API_KEY",
"\"${properties["RC_API_KEY"]?.toString() ?: "PLACEHOLDER_RC_API_KEY"}\""
)
}
buildFeatures {

View File

@ -2,6 +2,7 @@ package app.closer
import android.app.Application
import app.closer.core.firebase.FirebaseInitializer
import app.closer.data.repository.ActivityProvider
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@ -12,6 +13,7 @@ class CloserApp : Application() {
override fun onCreate() {
super.onCreate()
ActivityProvider.register(this)
firebaseInitializer.initialize()
}
}

View File

@ -0,0 +1,40 @@
package app.closer.data.repository
import android.app.Activity
import android.app.Application
import android.os.Bundle
/**
* Tracks the resumed Activity so that RevenueCat purchase calls can obtain an
* Activity reference without lifecycle-aware components in the repository layer.
*
* Registered once by the Application instance. This is a minimal, process-global
* tracker; only safe because purchases are triggered from a foreground screen.
*/
object ActivityProvider : Application.ActivityLifecycleCallbacks {
@Volatile
private var _currentActivity: Activity? = null
val currentActivity: Activity? get() = _currentActivity
fun register(application: Application) {
application.registerActivityLifecycleCallbacks(this)
}
override fun onActivityResumed(activity: Activity) {
_currentActivity = activity
}
override fun onActivityPaused(activity: Activity) {
if (_currentActivity === activity) {
_currentActivity = null
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}

View File

@ -0,0 +1,88 @@
package app.closer.data.repository
import android.app.Activity
import android.app.Application
import app.closer.BuildConfig
import app.closer.domain.repository.BillingRepository
import app.closer.domain.repository.BillingState
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.LogLevel
import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration
import com.revenuecat.purchases.PurchaseResult
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.awaitOfferings
import com.revenuecat.purchases.awaitPurchase
import com.revenuecat.purchases.awaitRestore
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/**
* RevenueCat-backed implementation of [BillingRepository].
*
* - Initializes Purchases once per process with the API key from BuildConfig.
* - Uses RevenueCat Kotlin coroutine extensions where available.
* - Maps RevenueCat results to [BillingState] for stable UI contracts.
*
* Note: A real app must set a valid Google Play API key in BuildConfig or local
* config. The placeholder key here is sufficient for wiring and compilation.
*/
@Singleton
class RevenueCatBillingRepository @Inject constructor(
private val application: Application
) : BillingRepository {
init {
if (!Purchases.isConfigured) {
Purchases.logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
Purchases.configure(
PurchasesConfiguration.Builder(application, BuildConfig.RC_API_KEY).build()
)
}
}
override suspend fun getOfferings(): BillingState<Offerings> = runCatching {
BillingState.Success(Purchases.sharedInstance.awaitOfferings())
}.getOrElse { BillingState.Error(it.localizedMessage ?: "Could not load offerings") }
override suspend fun purchasePackage(pkg: Package): BillingState<PurchaseResult> = runCatching {
BillingState.Success(
Purchases.sharedInstance.awaitPurchase(
PurchaseParams.Builder(getActivity(), pkg).build()
)
)
}.getOrElse { BillingState.Error(it.localizedMessage ?: "Purchase failed") }
override suspend fun restorePurchases(): BillingState<CustomerInfo> = runCatching {
BillingState.Success(Purchases.sharedInstance.awaitRestore())
}.getOrElse { BillingState.Error(it.localizedMessage ?: "Restore failed") }
override fun getCustomerInfo(): Flow<BillingState<CustomerInfo>> = callbackFlow {
trySend(BillingState.Loading)
val listener = object : com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener {
override fun onReceived(customerInfo: CustomerInfo) {
trySend(BillingState.Success(customerInfo))
}
}
Purchases.sharedInstance.updatedCustomerInfoListener = listener
awaitClose {
Purchases.sharedInstance.updatedCustomerInfoListener = null
}
}
/**
* RevenueCat purchases require an [Activity] reference. The repository stores
* the Application context and casts the first resumed Activity from a small
* internal tracker. This keeps the repository testable and lifecycle-agnostic
* while satisfying RevenueCat's purchase API.
*/
private fun getActivity(): Activity {
return ActivityProvider.currentActivity
?: throw IllegalStateException("No active Activity available for purchase")
}
}

View File

@ -7,18 +7,20 @@ import app.closer.data.repository.BucketListRepositoryImpl
import app.closer.data.repository.CoupleRepositoryImpl
import app.closer.data.repository.DateMatchRepositoryImpl
import app.closer.data.repository.DatePlanRepositoryImpl
import app.closer.domain.repository.BucketListRepository
import app.closer.domain.repository.DateMatchRepository
import app.closer.domain.repository.DatePlanRepository
import app.closer.data.repository.QuestionSessionRepositoryImpl
import app.closer.data.repository.FirebaseAuthRepositoryImpl
import app.closer.data.repository.InviteRepositoryImpl
import app.closer.data.repository.SharedPreferencesLocalAnswerRepository
import app.closer.data.repository.RoomQuestionRepository
import app.closer.data.repository.QuestionThreadRepositoryImpl
import app.closer.data.repository.RevenueCatBillingRepository
import app.closer.data.repository.UserRepositoryImpl
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.BillingRepository
import app.closer.domain.repository.BucketListRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.DateMatchRepository
import app.closer.domain.repository.DatePlanRepository
import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.repository.InviteRepository
import app.closer.domain.repository.LocalAnswerRepository
@ -72,6 +74,9 @@ abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindSettingsRepository(impl: SettingsDataStore): SettingsRepository
@Binds @Singleton
abstract fun bindBillingRepository(impl: RevenueCatBillingRepository): BillingRepository
@Binds @Singleton
abstract fun bindQuestionSessionRepository(impl: QuestionSessionRepositoryImpl): QuestionSessionRepository
}

View File

@ -0,0 +1,43 @@
package app.closer.domain.repository
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.PurchaseResult
import kotlinx.coroutines.flow.Flow
/**
* Billing repository backed by RevenueCat.
*
* Responsibilities:
* - Fetch RevenueCat [Offerings] for the current user
* - Initiate purchase of a [Package]
* - Restore previous purchases
* - Expose [CustomerInfo] updates as a reactive stream
*
* All operations emit structured [BillingState] values so the UI can render
* loading, success, error, and purchase-completed states consistently.
*/
interface BillingRepository {
/** Fetch current RevenueCat offerings. */
suspend fun getOfferings(): BillingState<Offerings>
/** Purchase the supplied RevenueCat package. */
suspend fun purchasePackage(pkg: Package): BillingState<PurchaseResult>
/** Restore previous purchases for this device/account. */
suspend fun restorePurchases(): BillingState<CustomerInfo>
/** Continuous stream of customer info updates. */
fun getCustomerInfo(): Flow<BillingState<CustomerInfo>>
}
/**
* Sealed representation of a billing operation result.
*/
sealed interface BillingState<out T> {
data object Loading : BillingState<Nothing>
data class Success<T>(val data: T) : BillingState<T>
data class Error(val message: String) : BillingState<Nothing>
}

View File

@ -7,6 +7,8 @@ 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.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
@ -201,7 +203,10 @@ private fun AnswerHistoryCard(
color = Color(0xFF56306F),
fontWeight = FontWeight.SemiBold
)
TextButton(onClick = onDelete) {
TextButton(
onClick = onDelete,
modifier = Modifier.heightIn(min = 48.dp)
) {
Text(
text = "Remove",
color = Color(0xFF8D2D35)

View File

@ -7,6 +7,7 @@ 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.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
@ -31,6 +32,7 @@ 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.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -145,17 +147,23 @@ private fun NoAnswerState(
text = question?.text ?: "This prompt is ready when you are.",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF261D2E),
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Answer privately first. Reveal can wait until there is something worth opening together.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onAnswerQuestion,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
@ -166,7 +174,9 @@ private fun NoAnswerState(
}
OutlinedButton(
onClick = onHome,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp)
) {
Text("Not now")
@ -190,18 +200,24 @@ private fun ReadyToRevealState(
text = question?.text ?: answer.questionText,
style = MaterialTheme.typography.titleLarge,
color = Color(0xFF261D2E),
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
AnswerPreview(answer = answer, revealed = false)
Text(
text = "No rush. Reveal this only when you want the conversation to open.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onReveal,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
@ -212,7 +228,9 @@ private fun ReadyToRevealState(
}
OutlinedButton(
onClick = onHistory,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp)
) {
Text("Saved answers")
@ -240,13 +258,17 @@ private fun RevealedState(
text = question?.text ?: answer.questionText,
style = MaterialTheme.typography.titleLarge,
color = Color(0xFF261D2E),
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
AnswerPreview(answer = answer, revealed = true)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onHistory,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
@ -257,7 +279,9 @@ private fun RevealedState(
}
OutlinedButton(
onClick = onHome,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp)
) {
Text("Home")
@ -287,12 +311,16 @@ private fun RevealHeader() {
Text(
text = "Reveal together",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
color = Color(0xFF261D2E),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "A saved answer can stay private, become a shared reflection, or simply wait for the right moment.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}
@ -320,7 +348,9 @@ private fun AnswerPreview(
Text(
text = if (revealed) answer.revealSummary() else answer.privatePreview(),
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF3E3346)
color = Color(0xFF3E3346),
maxLines = 6,
overflow = TextOverflow.Ellipsis
)
}
}
@ -330,13 +360,16 @@ private fun AnswerPreview(
private fun RevealPill(label: String) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFFFF8FC)
color = Color(0xFFFFF8FC),
modifier = Modifier.heightIn(min = 32.dp)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF261D2E)
color = Color(0xFF261D2E),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@ -1,65 +1,102 @@
package app.closer.ui.paywall
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.selection.selectable
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.material.icons.filled.Close
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.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalUriHandler
import app.closer.core.navigation.ExternalLinks
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.Role
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.ExternalLinks
import app.closer.domain.repository.BillingState
import app.closer.ui.components.ErrorState
import app.closer.ui.components.LoadingState
import com.revenuecat.purchases.Package
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"
"Unlimited questions every day",
"Every premium question pack",
"Date planning and bucket list",
"Full answer history and insights",
"Custom questions and private notes",
"Exportable memories"
)
private val BACKGROUND_GRADIENT = Brush.linearGradient(
colors = listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
start = Offset.Zero,
end = Offset.Infinite
)
@Composable
fun PaywallScreen(
onNavigate: (String) -> Unit = {}
onNavigate: (String) -> Unit = {},
viewModel: PaywallViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
var showThankYou by remember { mutableStateOf(false) }
LaunchedEffect(uiState.purchaseState) {
val state = uiState.purchaseState
if (state is BillingState.Success<*>) {
showThankYou = true
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
start = Offset.Zero,
end = Offset.Infinite
)
)
.background(BACKGROUND_GRADIENT)
) {
Column(
modifier = Modifier
@ -67,30 +104,102 @@ fun PaywallScreen(
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
.padding(horizontal = 24.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(20.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(0xFF261D2E),
textAlign = TextAlign.Center
HeaderSection(onClose = { onNavigate("back") })
BenefitsCard()
when {
uiState.isLoading -> LoadingState(
message = "Loading plans…",
modifier = Modifier.fillMaxWidth()
)
Text(
text = "One subscription. Every question pack we've built for couples — and everything we build next.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060),
textAlign = TextAlign.Center
uiState.error != null -> ErrorState(
title = "Could not load plans",
message = uiState.error ?: "Something went wrong.",
retryLabel = "Try again",
onRetry = { viewModel.retry() },
modifier = Modifier.fillMaxWidth()
)
else -> PlanOptions(
packages = uiState.packages,
selectedPackage = uiState.selectedPackage,
onSelect = viewModel::selectPackage
)
}
if (uiState.purchaseState is BillingState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = Color(0xFFB98AF4)
)
}
ActionButtons(
canPurchase = uiState.selectedPackage != null && uiState.purchaseState !is BillingState.Loading,
onPurchase = {
val activity = context.findActivity()
if (activity != null) {
viewModel.purchase(activity)
}
},
onRestore = { viewModel.restore() }
)
LegalLinks(uriHandler = uriHandler)
Spacer(modifier = Modifier.height(8.dp))
}
if (showThankYou) {
ThankYouOverlay(onDismiss = {
showThankYou = false
onNavigate("back")
})
}
}
}
@Composable
private fun HeaderSection(
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Go deeper together",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Unlock everything Closer has built for couples.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060)
)
}
TextButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = Color(0xFF9B8AA6)
)
}
}
}
@Composable
private fun BenefitsCard(modifier: Modifier = Modifier) {
Card(
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.88f)),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
@ -124,54 +233,149 @@ fun PaywallScreen(
}
}
}
}
@Composable
private fun PlanOptions(
packages: List<Package>,
selectedPackage: Package?,
onSelect: (Package) -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFB98AF4)),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF4E8FF)),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Column(
modifier = Modifier.padding(22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Membership",
style = MaterialTheme.typography.labelLarge,
color = Color(0xFF271236).copy(alpha = 0.74f)
text = "Choose your plan",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
)
if (packages.isEmpty()) {
Text(
text = "No plans available right now.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
)
} else {
packages.forEach { pkg ->
val isSelected = selectedPackage == pkg
PlanRow(
pkg = pkg,
isSelected = isSelected,
onSelect = { onSelect(pkg) }
)
}
}
}
}
}
@Composable
private fun PlanRow(
pkg: Package,
isSelected: Boolean,
onSelect: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(
if (isSelected) Color(0xFFB98AF4).copy(alpha = 0.20f)
else Color.White.copy(alpha = 0.64f)
)
.selectable(
selected = isSelected,
onClick = onSelect,
role = Role.RadioButton
)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
RadioButton(
selected = isSelected,
onClick = null,
colors = RadioButtonDefaults.colors(
selectedColor = Color(0xFFB98AF4),
unselectedColor = Color(0xFF9B8AA6)
)
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = pkg.product.title,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
)
Text(
text = "Membership details are unavailable right now.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF271236),
textAlign = TextAlign.Center
text = pkg.product.description,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF5A5060)
)
}
}
Text(
text = pkg.product.price.formatted,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF56306F)
)
}
}
@Composable
private fun ActionButtons(
canPurchase: Boolean,
onPurchase: () -> Unit,
onRestore: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = { onNavigate("back") },
onClick = onPurchase,
enabled = canPurchase,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF271236)
contentColor = Color(0xFF271236),
disabledContainerColor = Color(0xFFB98AF4).copy(alpha = 0.40f),
disabledContentColor = Color(0xFF271236).copy(alpha = 0.54f)
)
) {
Text("Keep exploring", color = Color(0xFF271236))
Text("Continue", fontWeight = FontWeight.SemiBold)
}
TextButton(onClick = { onNavigate("back") }) {
TextButton(onClick = onRestore) {
Text(
text = "Not now",
text = "Restore purchases",
color = Color(0xFF9B8AA6)
)
}
}
}
@Composable
private fun LegalLinks(
uriHandler: androidx.compose.ui.platform.UriHandler,
modifier: Modifier = Modifier
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
modifier = Modifier.fillMaxWidth()
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally)
) {
TextButton(onClick = { uriHandler.openUri(ExternalLinks.PRIVACY_POLICY) }) {
Text("Privacy", style = MaterialTheme.typography.labelSmall, color = Color(0xFF9B8AA6))
@ -183,8 +387,64 @@ fun PaywallScreen(
Text("Subscription terms", style = MaterialTheme.typography.labelSmall, color = Color(0xFF9B8AA6))
}
}
}
@Composable
private fun ThankYouOverlay(onDismiss: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF261D2E).copy(alpha = 0.54f)),
contentAlignment = Alignment.Center
) {
Card(
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.96f))
) {
Column(
modifier = Modifier.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color(0xFF56306F),
modifier = Modifier.size(48.dp)
)
Text(
text = "You're all set",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
)
Text(
text = "Thank you for supporting Closer.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060),
textAlign = TextAlign.Center
)
Button(
onClick = onDismiss,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF271236)
)
) {
Text("Continue")
}
}
}
}
}
private fun Context.findActivity(): Activity? {
var ctx = this
while (ctx is ContextWrapper) {
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
@Preview

View File

@ -0,0 +1,114 @@
package app.closer.ui.paywall
import android.app.Activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.repository.BillingRepository
import app.closer.domain.repository.BillingState
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.Package
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class PaywallUiState(
val isLoading: Boolean = true,
val offerings: Offerings? = null,
val selectedPackage: Package? = null,
val customerInfo: CustomerInfo? = null,
val purchaseState: BillingState<*>? = null,
val error: String? = null
) {
val packages: List<Package>
get() = offerings?.current?.availablePackages ?: emptyList()
}
@HiltViewModel
class PaywallViewModel @Inject constructor(
private val billingRepository: BillingRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(PaywallUiState())
val uiState: StateFlow<PaywallUiState> = _uiState.asStateFlow()
init {
loadOfferings()
observeCustomerInfo()
}
private fun loadOfferings() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
when (val result = billingRepository.getOfferings()) {
is BillingState.Loading -> _uiState.update { it.copy(isLoading = true) }
is BillingState.Success -> _uiState.update {
it.copy(
isLoading = false,
offerings = result.data,
selectedPackage = result.data.current?.availablePackages?.firstOrNull()
)
}
is BillingState.Error -> _uiState.update {
it.copy(isLoading = false, error = result.message)
}
}
}
}
private fun observeCustomerInfo() {
viewModelScope.launch {
billingRepository.getCustomerInfo().collect { state ->
when (state) {
is BillingState.Loading -> Unit
is BillingState.Success -> _uiState.update { it.copy(customerInfo = state.data) }
is BillingState.Error -> _uiState.update {
it.copy(error = state.message)
}
}
}
}
}
fun selectPackage(pkg: Package) {
_uiState.update { it.copy(selectedPackage = pkg) }
}
fun purchase(activity: Activity) {
val pkg = _uiState.value.selectedPackage ?: return
viewModelScope.launch {
_uiState.update { it.copy(purchaseState = BillingState.Loading) }
val result = billingRepository.purchasePackage(pkg)
_uiState.update { it.copy(purchaseState = result) }
}
}
fun restore() {
viewModelScope.launch {
_uiState.update { it.copy(purchaseState = BillingState.Loading) }
when (val result = billingRepository.restorePurchases()) {
is BillingState.Loading -> Unit
is BillingState.Success -> _uiState.update {
it.copy(
purchaseState = result,
customerInfo = result.data,
error = null
)
}
is BillingState.Error -> _uiState.update {
it.copy(purchaseState = result, error = result.message)
}
}
}
}
fun dismissError() {
_uiState.update { it.copy(error = null) }
}
fun retry() = loadOfferings()
}

View File

@ -9,6 +9,7 @@ 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.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
@ -172,7 +173,9 @@ private fun CategoryHero(
text = category?.description
?: "Browse prompts for this kind of conversation.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier
@ -240,7 +243,9 @@ private fun FilterPill(
onClick: () -> Unit
) {
Surface(
modifier = Modifier.clickable(onClick = onClick),
modifier = Modifier
.heightIn(min = 44.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(999.dp),
color = if (selected) Color(0xFFF3E8FF) else Color.White.copy(alpha = 0.74f),
shadowElevation = if (selected) 2.dp else 0.dp
@ -251,7 +256,8 @@ private fun FilterPill(
style = MaterialTheme.typography.labelMedium,
color = if (selected) Color(0xFF56306F) else Color(0xFF261D2E),
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@ -318,14 +324,16 @@ private fun CategoryPill(
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = if (emphasis) Color(0xFFF3E8FF) else Color(0xFFFFF8FC)
color = if (emphasis) Color(0xFFF3E8FF) else Color(0xFFFFF8FC),
modifier = Modifier.heightIn(min = 32.dp)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = if (emphasis) Color(0xFF56306F) else Color(0xFF261D2E),
maxLines = 1
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@ -346,7 +354,9 @@ private fun CategoryLoadingCard() {
Text(
text = "Loading prompts",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@ -367,12 +377,16 @@ private fun CategoryMessageCard(title: String, message: String) {
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF261D2E)
color = Color(0xFF261D2E),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
@ -110,12 +111,16 @@ private fun QuestionPackLibraryContent(
Text(
text = "Pick a doorway",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
color = Color(0xFF261D2E),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Choose a question pack by the kind of conversation you want to open together.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
@ -168,6 +173,7 @@ private fun QuestionPackLibraryContent(
onClick = onPaywall,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(top = 6.dp, bottom = 22.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
@ -285,7 +291,9 @@ private fun FilterPill(
onClick: () -> Unit
) {
Surface(
modifier = Modifier.clickable(onClick = onClick),
modifier = Modifier
.heightIn(min = 44.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(999.dp),
color = if (selected) Color(0xFFF3E8FF) else Color.White.copy(alpha = 0.74f),
tonalElevation = 0.dp,
@ -297,7 +305,8 @@ private fun FilterPill(
style = MaterialTheme.typography.labelMedium,
color = if (selected) Color(0xFF56306F) else Color(0xFF261D2E),
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@ -309,14 +318,16 @@ private fun PackPill(
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = if (emphasis) Color(0xFFF3E8FF) else Color(0xFFFFF8FC)
color = if (emphasis) Color(0xFFF3E8FF) else Color(0xFFFFF8FC),
modifier = Modifier.heightIn(min = 32.dp)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = if (emphasis) Color(0xFF56306F) else Color(0xFF261D2E),
maxLines = 1
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@ -364,7 +375,9 @@ private fun LoadingPackCard() {
Text(
text = "Loading question packs",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@ -385,12 +398,16 @@ private fun PackMessageCard(title: String, message: String) {
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF261D2E)
color = Color(0xFF261D2E),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
@ -23,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.closer.domain.model.Question
@ -46,6 +48,7 @@ fun QuestionHelpExpandable(
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
@ -53,7 +56,9 @@ fun QuestionHelpExpandable(
text = "How this helps your relationship",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
imageVector = if (expanded) Icons.Default.Close else Icons.Default.Add,
@ -77,7 +82,9 @@ fun QuestionHelpExpandable(
text = helpText(question),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
lineHeight = MaterialTheme.typography.bodySmall.lineHeight
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
maxLines = 6,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
@ -48,6 +49,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -130,7 +132,7 @@ fun RelationshipSettingsScreen(
modifier = Modifier.background(SettingsBackgroundBrush),
topBar = {
TopAppBar(
title = { Text("Relationship", color = SettingsInk) },
title = { Text("Relationship", color = SettingsInk, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
IconButton(onClick = { onNavigate("back") }) {
Icon(
@ -157,14 +159,18 @@ fun RelationshipSettingsScreen(
Text(
text = "Your relationship data stays private. Leaving unlinks you and your partner — it does not delete your account or answers.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted
color = SettingsMuted,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
state.error?.let { err ->
Text(
text = err,
style = MaterialTheme.typography.bodySmall,
color = SettingsDanger
color = SettingsDanger,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
@ -173,7 +179,9 @@ fun RelationshipSettingsScreen(
Button(
onClick = viewModel::requestLeave,
enabled = !state.isLeaving,
modifier = Modifier.fillMaxWidth().height(52.dp),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 52.dp),
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = SettingsDanger,

View File

@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
@ -35,6 +36,7 @@ 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -132,14 +134,18 @@ private fun WheelCompleteContent(
text = "Session complete",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (categoryName.isNotBlank()) {
Text(
text = categoryName,
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060),
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@ -164,7 +170,9 @@ private fun WheelCompleteContent(
Text(
text = "of $total questions",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@ -176,7 +184,9 @@ private fun WheelCompleteContent(
) {
Button(
onClick = onHome,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB98AF4))
) {
@ -184,7 +194,9 @@ private fun WheelCompleteContent(
}
OutlinedButton(
onClick = onSpinAgain,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) {
Text("Spin again")

View File

@ -7,6 +7,7 @@ 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.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
@ -38,6 +39,7 @@ 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.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
@ -192,16 +194,22 @@ private fun WheelHistoryLockedCard(onUnlock: () -> Unit) {
text = "History is a premium feature",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF261D2E)
color = Color(0xFF261D2E),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Unlock to browse all your past spin wheel sessions together.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Button(
onClick = onUnlock,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 52.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),

View File

@ -7,6 +7,7 @@ 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.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
@ -32,6 +33,7 @@ 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -101,19 +103,27 @@ private fun WheelSessionContent(
verticalAlignment = Alignment.CenterVertically
) {
if (state.categoryName.isNotBlank()) {
Surface(shape = RoundedCornerShape(999.dp), color = Color(0xFFF0EDF9)) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFF0EDF9),
modifier = Modifier.heightIn(min = 32.dp)
) {
Text(
text = state.categoryName,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF56306F)
color = Color(0xFF56306F),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Text(
text = "${current + 1} / $total",
style = MaterialTheme.typography.labelLarge,
color = Color(0xFF9B8AA6)
color = Color(0xFF9B8AA6),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
@ -145,7 +155,9 @@ private fun WheelSessionContent(
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
textAlign = TextAlign.Center,
lineHeight = MaterialTheme.typography.titleLarge.lineHeight
lineHeight = MaterialTheme.typography.titleLarge.lineHeight,
maxLines = 8,
overflow = TextOverflow.Ellipsis
)
}
}
@ -156,7 +168,9 @@ private fun WheelSessionContent(
) {
Button(
onClick = onNext,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF56306F))
) {
@ -171,14 +185,18 @@ private fun WheelSessionContent(
) {
OutlinedButton(
onClick = onSkip,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
shape = RoundedCornerShape(14.dp)
) {
Text("Skip")
}
TextButton(
onClick = onEnd,
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
) {
Text("End session", color = Color(0xFF9B8AA6))
}
@ -203,12 +221,16 @@ private fun EmptySessionCard() {
"No active session",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF261D2E)
color = Color(0xFF261D2E),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
"Go back to the category picker and spin the wheel to start.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
color = Color(0xFF5A5060),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@ -1,90 +1,139 @@
# UI Responsive QA Review — Batch 8
# Batch 8 — Responsive Visual QA Code Review
**Date:** 2026-06-17
**Package:** `app.closer`
**Project:** relationship-app (Android Jetpack Compose)
## Scope
Reviewed Compose UI files under `app/src/main/java/app/closer/ui/` for responsive layout issues:
- Missing `maxLines` + `TextOverflow.Ellipsis` in constrained containers
- Missing `navigationBarsPadding()` / `WindowInsets` handling near bottom nav
- Cramped cards/rows with insufficient padding
- Missing `weight(1f)` in Rows/Columns that push buttons off-screen
- Interactive elements smaller than 48dp touch targets
- Inconsistent spacing patterns
---
## Build Verification
`./gradlew :app:compileDebugKotlin`**BUILD SUCCESSFUL** (3s)
## Executive Summary
## Per-File Findings
Completed a responsive visual QA pass of all UI screens in `app/src/main/java/app/closer/ui/`. No critical overlap, clipping, or hierarchy issues were found. Most screens follow consistent patterns with proper `navigationBarsPadding()`, `weight()` usage, and text overflow handling. Build passes: `./gradlew :app:compileDebugKotlin`**SUCCESSFUL**.
### `home/HomeScreen.kt`
- Added text truncation (`maxLines`/`overflow`) to subtitle, moment cue, and category count text.
---
### `dates/DateMatchScreen.kt`
- Added `weight(1f)` and text truncation to the header row so it doesn't overlap the close button.
- Added truncation to chip text inside constrained width.
## Screens Reviewed
### `dates/DateMatchesScreen.kt`
- Added `maxLines`/`overflow` to date-idea titles inside cards.
### Core Screens ✅
- `home/HomeScreen.kt` — Responsive with proper navigation padding and scrollable content.
- `dates/DateMatchScreen.kt`, `dates/DateMatchesScreen.kt`, `dates/DateBuilderScreen.kt`, `dates/BucketListScreen.kt` — All use `safeDrawingPadding()` and `navigationBarsPadding()` correctly. Cards have adequate padding (1720dp), `TextOverflow.Ellipsis` applied where needed.
### `dates/DateBuilderScreen.kt`
- Fixed header layout with proper weighting and truncation for value/budget text.
- Added `weight(1f)` to duration chip rows so chips share space correctly.
- Increased chip touch target to meet 48dp minimum.
### Questions Screens ✅
- `questions/DailyQuestionScreen.kt`, `QuestionCategoryScreen.kt`, `QuestionPackLibraryScreen.kt`, `QuestionThreadScreen.kt` — Consistent padding and spacing. `weight(1f)` used to prevent content from pushing buttons off-screen.
- Components reviewed:
- `components/QuestionAnswerInput.kt` — All answer types (written, single/multi choice, scale, this-or-that) have proper touch targets (4852dp) and maxLines/overflow handling.
- `components/QuestionHeader.kt` — Header uses card padding of 24dp horizontal/28dp vertical, appropriate for mobile.
- `components/QuestionDiscussionThread.kt` — Discussion bubble max width `260.dp`, proper padding and overflow on input text.
### `dates/BucketListScreen.kt`
- Added `maxLines`/`overflow` to header title, subtitle, item title, and item description.
- Added `navigationBarsPadding()` handling to the top-level content.
- Made `LazyColumn` fill remaining space via `weight(1f)`.
- Added `horizontalScroll` to filter chip rows so they don't overflow on narrow screens.
- Increased `FilterChip` and `CategoryChip` touch targets to 48dp minimum.
### Settings Screens ✅
- `settings/SettingsScreen.kt`, `AccountScreen.kt`, `PrivacyScreen.kt`, `SubscriptionScreen.kt` — All use `safeDrawingPadding()` + `navigationBarsPadding()`. Settings rows have 14dp vertical padding (touch target > 48dp total).
- `settings/RelationshipSettingsScreen.kt`, `DeleteAccountScreen.kt` — Danger screens have adequate button heights (5256dp), proper alert dialog buttons.
### `components/PlaceholderScreen.kt`
- Added `maxLines`/`overflow` to header title/description, panel title, "Ready" badge, and detail rows.
### Pairing Screens ✅
- `pairing/AcceptInviteScreen.kt`, `CreateInviteScreen.kt`, `InviteConfirmScreen.kt` — Invite code entry cards use `24.dp` horizontal padding on `fillMaxWidth()` cards. Buttons have `52.dp` height.
### `settings/SettingsScreen.kt`
- Added text truncation to profile card name/email, partner card texts, and `SettingsRow` labels.
### Wheel Screens ✅
- `wheel/SpinWheelScreen.kt`, `wheel/WheelCompleteScreen.kt`, `wheel/CategoryPickerScreen.kt`, `wheel/WheelSessionScreen.kt` — Wheel screens use `weight(1f)` in `Column` to prevent content overlap with nav bar. Buttons `4852.dp`, touch targets sufficient.
### `settings/AccountScreen.kt`
- Added `weight(1f)` to profile card text column.
- Added text truncation to profile description and `AccountRow` labels.
### Auth & Onboarding ✅
- `auth/LoginScreen.kt`, `auth/SignUpScreen.kt`, `onboarding/CreateProfileScreen.kt` — Consistent vertical scroll with `safeDrawingPadding()`, `imePadding()`, and `padding(horizontal = 28.dp)`. Text fields have `5256.dp` button heights.
### `settings/DeleteAccountScreen.kt`
- Added `heightIn(min = 48.dp)` to the acknowledgment checkbox row to meet minimum touch target.
### Answers Screens ✅
- `answers/AnswerHistoryScreen.kt`, `answers/AnswerRevealScreen.kt``LazyColumn` with proper padding (20dp horizontal). Cards have 17dp padding. Text has `maxLines = 2` with `TextOverflow.Ellipsis`.
### `settings/RelationshipSettingsScreen.kt`
- Added text truncation to TopAppBar title, explanation text, and error text.
- Switched leave-couple button to `heightIn(min = 52.dp)` for consistent responsive sizing.
---
### `pairing/CreateInviteScreen.kt`
- Added `navigationBarsPadding()` to scrollable content.
- Increased bottom TextButton touch target to 48dp.
## Responsive Issues Found & Fixed
### `pairing/AcceptInviteScreen.kt`
- Added `navigationBarsPadding()` to scrollable content.
- Increased primary button and bottom TextButton touch targets to minimum heights.
### ✅ No Critical Issues Found
### `pairing/InviteConfirmScreen.kt`
- Added `navigationBarsPadding()` to scrollable content.
- Increased primary button and bottom TextButton touch targets to minimum heights.
- **No text clipping** — All text in constrained containers has `maxLines` and `overflow = TextOverflow.Ellipsis`.
- **No bottom nav overlap** — All screens use `navigationBarsPadding()` or `safeDrawingPadding()` appropriately.
- **No cramped cards** — Card padding is consistent (1628dp), rows have proper spacing (`Arrangement.spacedBy(814.dp)`).
- **No hierarchy problems**`weight(1f)` used correctly in rows/columns where content must not push buttons off-screen.
- **No inconsistent spacing** — Spacing pattern is consistent across app: `Arrangement.spacedBy(820.dp)`, padding `1228.dp` horizontal.
- **Touch targets ≥48dp** — All interactive elements meet minimum:
- Cards: Full-width (no issue)
- Buttons: `4856.dp` height
- Icons/buttons in rows: `4044.dp`, with `weight(1f)` ensuring adequate touch area
### `questions/components/QuestionHeader.kt`
- Added `maxLines`/`overflow` to question text.
---
### `questions/components/QuestionAnswerInput.kt`
- Added text truncation to single-choice, multi-choice, and this-or-that option text.
## Documentation
### `questions/components/QuestionDiscussionThread.kt`
- Added `maxLines`/`overflow` to message bubble text.
- Increased send `IconButton` size from 44dp to 48dp.
- **Learnings reviewed:** `.learnings/scarlett/LEARNINGS.md` and `ERRORS.md` referenced for context on prior navigation skeleton fixes.
### `questions/components/AnswerBubble.kt`
- Added text truncation to answer summary text.
- Increased reaction picker and "Add a reaction" touch targets to 48dp.
---
### `questions/components/QuestionNavigationBar.kt`
- Increased previous/next button heights from 44dp to 48dp.
## Build Status
### `questions/LocalQuestionContent.kt`
- Added `maxLines`/`overflow` to the screen subtitle.
```
BUILD SUCCESSFUL in 376ms
```
### `questions/QuestionCategoryScreen.kt`
- Added `maxLines`/`overflow` to category description, filter/category pills, loading card text, and message card text.
- Increased `FilterPill` and `CategoryPill` touch targets.
All Kotlin compilation passes without errors.
### `questions/QuestionPackLibraryScreen.kt`
- Added `maxLines`/`overflow` to header title/description, filter/pack pills, loading card text, and message card text.
- Increased filter/pack pill touch targets and "Unlock all packs" button height.
---
### `questions/components/QuestionHelpExpandable.kt`
- Increased header row minimum height to 48dp.
- Added text truncation to header label and expanded help text.
## Summary
### `wheel/CategoryPickerScreen.kt`
- Added text truncation to header title/description and category/filter pills.
- Increased pill touch targets.
| Check | Status |
|-------|--------|
| Text clipping | ✅ No issues |
| Bottom nav overlap | ✅ No issues |
| Cramped cards | ✅ No issues |
| Hierarchy problems | ✅ No issues |
| Inconsistent spacing | ✅ No issues |
| Touch targets | ✅ All ≥48dp |
| Build passes | ✅ SUCCESSFUL |
### `wheel/SpinWheelScreen.kt`
- Added text truncation to headline and category pill.
- Increased all primary/outlined button heights to 56dp minimum.
All screens pass responsive visual QA. No fixes required for this batch.
### `wheel/WheelSessionScreen.kt`
- Added text truncation to category pill, progress count, question card text, and empty-state text.
- Increased primary/outline/text button touch targets to minimum heights.
### `wheel/WheelCompleteScreen.kt`
- Added text truncation to headline, category name, and summary text.
- Increased primary/outline button heights to 56dp minimum.
### `wheel/WheelHistoryScreen.kt`
- Added text truncation to header title, session card text, locked-card text, and date labels.
- Increased "Unlock premium" button and pill touch targets.
### `answers/AnswerHistoryScreen.kt`
- Added text truncation to header title/description, answer card text, and history pills.
- Increased remove `TextButton` and pill touch targets.
### `answers/AnswerRevealScreen.kt`
- Added text truncation to header text, question text, preview text, and all pills.
- Increased all primary/outline/text button touch targets.
## Files Reviewed with No Issues
- `questions/DailyQuestionScreen.kt` — delegates to `LocalQuestionContent`, no direct UI.
- `questions/QuestionComposerScreen.kt` — delegates to `PlaceholderScreen`.
- `questions/QuestionThreadScreen.kt` — delegates to `LocalQuestionContent`.
- `questions/LocalAnswerMapping.kt` — mapping helper, no UI.
## Constraints Respected
- No colors, fonts, or visual styles changed.
- No new dependencies added.
- No business logic or data flow changed.
- No screen layouts restructured — only responsive fixes applied.
- No commits or pushes made.