feat(billing): RevenueCat SDK integration, BillingRepository, PaywallScreen + ViewModel, Hilt DI, navigation route (batch 9)
This commit is contained in:
parent
c9ff160bf3
commit
681cf7fb32
|
|
@ -18,6 +18,13 @@ android {
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
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 {
|
buildFeatures {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package app.closer
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import app.closer.core.firebase.FirebaseInitializer
|
import app.closer.core.firebase.FirebaseInitializer
|
||||||
|
import app.closer.data.repository.ActivityProvider
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ class CloserApp : Application() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
ActivityProvider.register(this)
|
||||||
firebaseInitializer.initialize()
|
firebaseInitializer.initialize()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,18 +7,20 @@ import app.closer.data.repository.BucketListRepositoryImpl
|
||||||
import app.closer.data.repository.CoupleRepositoryImpl
|
import app.closer.data.repository.CoupleRepositoryImpl
|
||||||
import app.closer.data.repository.DateMatchRepositoryImpl
|
import app.closer.data.repository.DateMatchRepositoryImpl
|
||||||
import app.closer.data.repository.DatePlanRepositoryImpl
|
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.QuestionSessionRepositoryImpl
|
||||||
import app.closer.data.repository.FirebaseAuthRepositoryImpl
|
import app.closer.data.repository.FirebaseAuthRepositoryImpl
|
||||||
import app.closer.data.repository.InviteRepositoryImpl
|
import app.closer.data.repository.InviteRepositoryImpl
|
||||||
import app.closer.data.repository.SharedPreferencesLocalAnswerRepository
|
import app.closer.data.repository.SharedPreferencesLocalAnswerRepository
|
||||||
import app.closer.data.repository.RoomQuestionRepository
|
import app.closer.data.repository.RoomQuestionRepository
|
||||||
import app.closer.data.repository.QuestionThreadRepositoryImpl
|
import app.closer.data.repository.QuestionThreadRepositoryImpl
|
||||||
|
import app.closer.data.repository.RevenueCatBillingRepository
|
||||||
import app.closer.data.repository.UserRepositoryImpl
|
import app.closer.data.repository.UserRepositoryImpl
|
||||||
import app.closer.domain.repository.AuthRepository
|
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.CoupleRepository
|
||||||
|
import app.closer.domain.repository.DateMatchRepository
|
||||||
|
import app.closer.domain.repository.DatePlanRepository
|
||||||
import app.closer.domain.repository.QuestionSessionRepository
|
import app.closer.domain.repository.QuestionSessionRepository
|
||||||
import app.closer.domain.repository.InviteRepository
|
import app.closer.domain.repository.InviteRepository
|
||||||
import app.closer.domain.repository.LocalAnswerRepository
|
import app.closer.domain.repository.LocalAnswerRepository
|
||||||
|
|
@ -72,6 +74,9 @@ abstract class RepositoryModule {
|
||||||
@Binds @Singleton
|
@Binds @Singleton
|
||||||
abstract fun bindSettingsRepository(impl: SettingsDataStore): SettingsRepository
|
abstract fun bindSettingsRepository(impl: SettingsDataStore): SettingsRepository
|
||||||
|
|
||||||
|
@Binds @Singleton
|
||||||
|
abstract fun bindBillingRepository(impl: RevenueCatBillingRepository): BillingRepository
|
||||||
|
|
||||||
@Binds @Singleton
|
@Binds @Singleton
|
||||||
abstract fun bindQuestionSessionRepository(impl: QuestionSessionRepositoryImpl): QuestionSessionRepository
|
abstract fun bindQuestionSessionRepository(impl: QuestionSessionRepositoryImpl): QuestionSessionRepository
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.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
|
||||||
|
|
@ -201,7 +203,10 @@ private fun AnswerHistoryCard(
|
||||||
color = Color(0xFF56306F),
|
color = Color(0xFF56306F),
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
TextButton(onClick = onDelete) {
|
TextButton(
|
||||||
|
onClick = onDelete,
|
||||||
|
modifier = Modifier.heightIn(min = 48.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Remove",
|
text = "Remove",
|
||||||
color = Color(0xFF8D2D35)
|
color = Color(0xFF8D2D35)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
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
|
||||||
|
|
@ -31,6 +32,7 @@ import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
@ -145,17 +147,23 @@ private fun NoAnswerState(
|
||||||
text = question?.text ?: "This prompt is ready when you are.",
|
text = question?.text ?: "This prompt is ready when you are.",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = Color(0xFF261D2E),
|
color = Color(0xFF261D2E),
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Answer privately first. Reveal can wait until there is something worth opening together.",
|
text = "Answer privately first. Reveal can wait until there is something worth opening together.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color(0xFF5A5060)
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onAnswerQuestion,
|
onClick = onAnswerQuestion,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 48.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color(0xFFB98AF4),
|
containerColor = Color(0xFFB98AF4),
|
||||||
|
|
@ -166,7 +174,9 @@ private fun NoAnswerState(
|
||||||
}
|
}
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onHome,
|
onClick = onHome,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 48.dp),
|
||||||
shape = RoundedCornerShape(16.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Text("Not now")
|
Text("Not now")
|
||||||
|
|
@ -190,18 +200,24 @@ private fun ReadyToRevealState(
|
||||||
text = question?.text ?: answer.questionText,
|
text = question?.text ?: answer.questionText,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = Color(0xFF261D2E),
|
color = Color(0xFF261D2E),
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
AnswerPreview(answer = answer, revealed = false)
|
AnswerPreview(answer = answer, revealed = false)
|
||||||
Text(
|
Text(
|
||||||
text = "No rush. Reveal this only when you want the conversation to open.",
|
text = "No rush. Reveal this only when you want the conversation to open.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color(0xFF5A5060)
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onReveal,
|
onClick = onReveal,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 48.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color(0xFFB98AF4),
|
containerColor = Color(0xFFB98AF4),
|
||||||
|
|
@ -212,7 +228,9 @@ private fun ReadyToRevealState(
|
||||||
}
|
}
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onHistory,
|
onClick = onHistory,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 48.dp),
|
||||||
shape = RoundedCornerShape(16.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Text("Saved answers")
|
Text("Saved answers")
|
||||||
|
|
@ -240,13 +258,17 @@ private fun RevealedState(
|
||||||
text = question?.text ?: answer.questionText,
|
text = question?.text ?: answer.questionText,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = Color(0xFF261D2E),
|
color = Color(0xFF261D2E),
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
AnswerPreview(answer = answer, revealed = true)
|
AnswerPreview(answer = answer, revealed = true)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onHistory,
|
onClick = onHistory,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 48.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color(0xFFB98AF4),
|
containerColor = Color(0xFFB98AF4),
|
||||||
|
|
@ -257,7 +279,9 @@ private fun RevealedState(
|
||||||
}
|
}
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onHome,
|
onClick = onHome,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 48.dp),
|
||||||
shape = RoundedCornerShape(16.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Text("Home")
|
Text("Home")
|
||||||
|
|
@ -287,12 +311,16 @@ private fun RevealHeader() {
|
||||||
Text(
|
Text(
|
||||||
text = "Reveal together",
|
text = "Reveal together",
|
||||||
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = Color(0xFF261D2E)
|
color = Color(0xFF261D2E),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "A saved answer can stay private, become a shared reflection, or simply wait for the right moment.",
|
text = "A saved answer can stay private, become a shared reflection, or simply wait for the right moment.",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = Color(0xFF5A5060)
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -320,7 +348,9 @@ private fun AnswerPreview(
|
||||||
Text(
|
Text(
|
||||||
text = if (revealed) answer.revealSummary() else answer.privatePreview(),
|
text = if (revealed) answer.revealSummary() else answer.privatePreview(),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
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) {
|
private fun RevealPill(label: String) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(999.dp),
|
shape = RoundedCornerShape(999.dp),
|
||||||
color = Color(0xFFFFF8FC)
|
color = Color(0xFFFFF8FC),
|
||||||
|
modifier = Modifier.heightIn(min = 32.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = Color(0xFF261D2E)
|
color = Color(0xFF261D2E),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,102 @@
|
||||||
package app.closer.ui.paywall
|
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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
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.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
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.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.RadioButtonDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
|
||||||
import app.closer.core.navigation.ExternalLinks
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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(
|
private val BENEFITS = listOf(
|
||||||
"Unlock every question pack — 5500+ prompts",
|
"Unlimited questions every day",
|
||||||
"Deeper intimacy, trust, and conflict tracks",
|
"Every premium question pack",
|
||||||
"Priority access to new seasonal packs",
|
"Date planning and bucket list",
|
||||||
"Support independent couple-focused development"
|
"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
|
@Composable
|
||||||
fun PaywallScreen(
|
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
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(
|
.background(BACKGROUND_GRADIENT)
|
||||||
Brush.linearGradient(
|
|
||||||
listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
|
|
||||||
start = Offset.Zero,
|
|
||||||
end = Offset.Infinite
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -67,126 +104,349 @@ fun PaywallScreen(
|
||||||
.safeDrawingPadding()
|
.safeDrawingPadding()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
.padding(horizontal = 24.dp, vertical = 28.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Column(
|
HeaderSection(onClose = { onNavigate("back") })
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
BenefitsCard()
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
when {
|
||||||
Text(
|
uiState.isLoading -> LoadingState(
|
||||||
text = "Go deeper together",
|
message = "Loading plans…",
|
||||||
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
|
modifier = Modifier.fillMaxWidth()
|
||||||
color = Color(0xFF261D2E),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
)
|
||||||
Text(
|
uiState.error != null -> ErrorState(
|
||||||
text = "One subscription. Every question pack we've built for couples — and everything we build next.",
|
title = "Could not load plans",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
message = uiState.error ?: "Something went wrong.",
|
||||||
color = Color(0xFF5A5060),
|
retryLabel = "Try again",
|
||||||
textAlign = TextAlign.Center
|
onRetry = { viewModel.retry() },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
else -> PlanOptions(
|
||||||
|
packages = uiState.packages,
|
||||||
|
selectedPackage = uiState.selectedPackage,
|
||||||
|
onSelect = viewModel::selectPackage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
if (uiState.purchaseState is BillingState.Loading) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
CircularProgressIndicator(
|
||||||
shape = RoundedCornerShape(28.dp),
|
modifier = Modifier.size(28.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.88f)),
|
color = Color(0xFFB98AF4)
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
|
)
|
||||||
) {
|
}
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(24.dp),
|
ActionButtons(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
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(),
|
||||||
|
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(0xFF261D2E)
|
||||||
|
)
|
||||||
|
BENEFITS.forEach { benefit ->
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF56306F),
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "What's included",
|
text = benefit,
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color(0xFF261D2E)
|
color = Color(0xFF261D2E)
|
||||||
)
|
)
|
||||||
BENEFITS.forEach { benefit ->
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color(0xFF56306F),
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = benefit,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = Color(0xFF261D2E)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFB98AF4)),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(22.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Membership",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = Color(0xFF271236).copy(alpha = 0.74f)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Membership details are unavailable right now.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = Color(0xFF271236),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { onNavigate("back") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = Color(0xFFB98AF4),
|
|
||||||
contentColor = Color(0xFF271236)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text("Keep exploring", color = Color(0xFF271236))
|
|
||||||
}
|
|
||||||
|
|
||||||
TextButton(onClick = { onNavigate("back") }) {
|
|
||||||
Text(
|
|
||||||
text = "Not now",
|
|
||||||
color = Color(0xFF9B8AA6)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
TextButton(onClick = { uriHandler.openUri(ExternalLinks.PRIVACY_POLICY) }) {
|
|
||||||
Text("Privacy", style = MaterialTheme.typography.labelSmall, color = Color(0xFF9B8AA6))
|
|
||||||
}
|
|
||||||
TextButton(onClick = { uriHandler.openUri(ExternalLinks.TERMS_OF_SERVICE) }) {
|
|
||||||
Text("Terms", style = MaterialTheme.typography.labelSmall, color = Color(0xFF9B8AA6))
|
|
||||||
}
|
|
||||||
TextButton(onClick = { uriHandler.openUri(ExternalLinks.SUBSCRIPTION_TERMS) }) {
|
|
||||||
Text("Subscription terms", style = MaterialTheme.typography.labelSmall, color = Color(0xFF9B8AA6))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlanOptions(
|
||||||
|
packages: List<Package>,
|
||||||
|
selectedPackage: Package?,
|
||||||
|
onSelect: (Package) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color(0xFFF4E8FF)),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(22.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
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 = 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 = onPurchase,
|
||||||
|
enabled = canPurchase,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFFB98AF4),
|
||||||
|
contentColor = Color(0xFF271236),
|
||||||
|
disabledContainerColor = Color(0xFFB98AF4).copy(alpha = 0.40f),
|
||||||
|
disabledContentColor = Color(0xFF271236).copy(alpha = 0.54f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Continue", fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onRestore) {
|
||||||
|
Text(
|
||||||
|
text = "Restore purchases",
|
||||||
|
color = Color(0xFF9B8AA6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LegalLinks(
|
||||||
|
uriHandler: androidx.compose.ui.platform.UriHandler,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
TextButton(onClick = { uriHandler.openUri(ExternalLinks.TERMS_OF_SERVICE) }) {
|
||||||
|
Text("Terms", style = MaterialTheme.typography.labelSmall, color = Color(0xFF9B8AA6))
|
||||||
|
}
|
||||||
|
TextButton(onClick = { uriHandler.openUri(ExternalLinks.SUBSCRIPTION_TERMS) }) {
|
||||||
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun PaywallScreenPreview() {
|
fun PaywallScreenPreview() {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
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
|
||||||
|
|
@ -172,7 +173,9 @@ private fun CategoryHero(
|
||||||
text = category?.description
|
text = category?.description
|
||||||
?: "Browse prompts for this kind of conversation.",
|
?: "Browse prompts for this kind of conversation.",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = Color(0xFF5A5060)
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -240,7 +243,9 @@ private fun FilterPill(
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier
|
||||||
|
.heightIn(min = 44.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(999.dp),
|
shape = RoundedCornerShape(999.dp),
|
||||||
color = if (selected) Color(0xFFF3E8FF) else Color.White.copy(alpha = 0.74f),
|
color = if (selected) Color(0xFFF3E8FF) else Color.White.copy(alpha = 0.74f),
|
||||||
shadowElevation = if (selected) 2.dp else 0.dp
|
shadowElevation = if (selected) 2.dp else 0.dp
|
||||||
|
|
@ -251,7 +256,8 @@ private fun FilterPill(
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = if (selected) Color(0xFF56306F) else Color(0xFF261D2E),
|
color = if (selected) Color(0xFF56306F) else Color(0xFF261D2E),
|
||||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
maxLines = 1
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,14 +324,16 @@ private fun CategoryPill(
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(999.dp),
|
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(
|
||||||
text = label,
|
text = label,
|
||||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = if (emphasis) Color(0xFF56306F) else Color(0xFF261D2E),
|
color = if (emphasis) Color(0xFF56306F) else Color(0xFF261D2E),
|
||||||
maxLines = 1
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -346,7 +354,9 @@ private fun CategoryLoadingCard() {
|
||||||
Text(
|
Text(
|
||||||
text = "Loading prompts",
|
text = "Loading prompts",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = Color(0xFF261D2E)
|
color = Color(0xFF261D2E),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = message,
|
text = message,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color(0xFF5A5060)
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
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
|
||||||
|
|
@ -110,12 +111,16 @@ private fun QuestionPackLibraryContent(
|
||||||
Text(
|
Text(
|
||||||
text = "Pick a doorway",
|
text = "Pick a doorway",
|
||||||
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = Color(0xFF261D2E)
|
color = Color(0xFF261D2E),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Choose a question pack by the kind of conversation you want to open together.",
|
text = "Choose a question pack by the kind of conversation you want to open together.",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = Color(0xFF5A5060)
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +173,7 @@ private fun QuestionPackLibraryContent(
|
||||||
onClick = onPaywall,
|
onClick = onPaywall,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp)
|
||||||
.padding(top = 6.dp, bottom = 22.dp),
|
.padding(top = 6.dp, bottom = 22.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
|
@ -285,7 +291,9 @@ private fun FilterPill(
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier
|
||||||
|
.heightIn(min = 44.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(999.dp),
|
shape = RoundedCornerShape(999.dp),
|
||||||
color = if (selected) Color(0xFFF3E8FF) else Color.White.copy(alpha = 0.74f),
|
color = if (selected) Color(0xFFF3E8FF) else Color.White.copy(alpha = 0.74f),
|
||||||
tonalElevation = 0.dp,
|
tonalElevation = 0.dp,
|
||||||
|
|
@ -297,7 +305,8 @@ private fun FilterPill(
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = if (selected) Color(0xFF56306F) else Color(0xFF261D2E),
|
color = if (selected) Color(0xFF56306F) else Color(0xFF261D2E),
|
||||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
maxLines = 1
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -309,14 +318,16 @@ private fun PackPill(
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(999.dp),
|
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(
|
||||||
text = label,
|
text = label,
|
||||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = if (emphasis) Color(0xFF56306F) else Color(0xFF261D2E),
|
color = if (emphasis) Color(0xFF56306F) else Color(0xFF261D2E),
|
||||||
maxLines = 1
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -364,7 +375,9 @@ private fun LoadingPackCard() {
|
||||||
Text(
|
Text(
|
||||||
text = "Loading question packs",
|
text = "Loading question packs",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = Color(0xFF261D2E)
|
color = Color(0xFF261D2E),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = message,
|
text = message,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color(0xFF5A5060)
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -23,6 +24,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.closer.domain.model.Question
|
import app.closer.domain.model.Question
|
||||||
|
|
||||||
|
|
@ -46,6 +48,7 @@ fun QuestionHelpExpandable(
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
@ -53,7 +56,9 @@ fun QuestionHelpExpandable(
|
||||||
text = "How this helps your relationship",
|
text = "How this helps your relationship",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (expanded) Icons.Default.Close else Icons.Default.Add,
|
imageVector = if (expanded) Icons.Default.Close else Icons.Default.Add,
|
||||||
|
|
@ -77,7 +82,9 @@ fun QuestionHelpExpandable(
|
||||||
text = helpText(question),
|
text = helpText(question),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight
|
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
||||||
|
maxLines = 6,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
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
|
||||||
|
|
@ -48,6 +49,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
@ -130,7 +132,7 @@ fun RelationshipSettingsScreen(
|
||||||
modifier = Modifier.background(SettingsBackgroundBrush),
|
modifier = Modifier.background(SettingsBackgroundBrush),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Relationship", color = SettingsInk) },
|
title = { Text("Relationship", color = SettingsInk, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { onNavigate("back") }) {
|
IconButton(onClick = { onNavigate("back") }) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -157,14 +159,18 @@ fun RelationshipSettingsScreen(
|
||||||
Text(
|
Text(
|
||||||
text = "Your relationship data stays private. Leaving unlinks you and your partner — it does not delete your account or answers.",
|
text = "Your relationship data stays private. Leaving unlinks you and your partner — it does not delete your account or answers.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = SettingsMuted
|
color = SettingsMuted,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
||||||
state.error?.let { err ->
|
state.error?.let { err ->
|
||||||
Text(
|
Text(
|
||||||
text = err,
|
text = err,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = SettingsDanger
|
color = SettingsDanger,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,7 +179,9 @@ fun RelationshipSettingsScreen(
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::requestLeave,
|
onClick = viewModel::requestLeave,
|
||||||
enabled = !state.isLeaving,
|
enabled = !state.isLeaving,
|
||||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 52.dp),
|
||||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = SettingsDanger,
|
containerColor = SettingsDanger,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
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
|
||||||
|
|
@ -35,6 +36,7 @@ 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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
@ -132,14 +134,18 @@ private fun WheelCompleteContent(
|
||||||
text = "Session complete",
|
text = "Session complete",
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = Color(0xFF261D2E),
|
color = Color(0xFF261D2E),
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
if (categoryName.isNotBlank()) {
|
if (categoryName.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = categoryName,
|
text = categoryName,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = Color(0xFF5A5060),
|
color = Color(0xFF5A5060),
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +170,9 @@ private fun WheelCompleteContent(
|
||||||
Text(
|
Text(
|
||||||
text = "of $total questions",
|
text = "of $total questions",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = Color(0xFF5A5060)
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +184,9 @@ private fun WheelCompleteContent(
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onHome,
|
onClick = onHome,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp),
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB98AF4))
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB98AF4))
|
||||||
) {
|
) {
|
||||||
|
|
@ -184,7 +194,9 @@ private fun WheelCompleteContent(
|
||||||
}
|
}
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onSpinAgain,
|
onClick = onSpinAgain,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp),
|
||||||
shape = RoundedCornerShape(18.dp)
|
shape = RoundedCornerShape(18.dp)
|
||||||
) {
|
) {
|
||||||
Text("Spin again")
|
Text("Spin again")
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
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
|
||||||
|
|
@ -38,6 +39,7 @@ import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
|
|
@ -192,16 +194,22 @@ private fun WheelHistoryLockedCard(onUnlock: () -> Unit) {
|
||||||
text = "History is a premium feature",
|
text = "History is a premium feature",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = Color(0xFF261D2E)
|
color = Color(0xFF261D2E),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Unlock to browse all your past spin wheel sessions together.",
|
text = "Unlock to browse all your past spin wheel sessions together.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color(0xFF5A5060)
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = onUnlock,
|
onClick = onUnlock,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 52.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color(0xFFB98AF4),
|
containerColor = Color(0xFFB98AF4),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
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
|
||||||
|
|
@ -32,6 +33,7 @@ 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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
@ -101,19 +103,27 @@ private fun WheelSessionContent(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (state.categoryName.isNotBlank()) {
|
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(
|
||||||
text = state.categoryName,
|
text = state.categoryName,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = Color(0xFF56306F)
|
color = Color(0xFF56306F),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "${current + 1} / $total",
|
text = "${current + 1} / $total",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
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),
|
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = Color(0xFF261D2E),
|
color = Color(0xFF261D2E),
|
||||||
textAlign = TextAlign.Center,
|
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(
|
Button(
|
||||||
onClick = onNext,
|
onClick = onNext,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp),
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF56306F))
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF56306F))
|
||||||
) {
|
) {
|
||||||
|
|
@ -171,14 +185,18 @@ private fun WheelSessionContent(
|
||||||
) {
|
) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onSkip,
|
onClick = onSkip,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 48.dp),
|
||||||
shape = RoundedCornerShape(14.dp)
|
shape = RoundedCornerShape(14.dp)
|
||||||
) {
|
) {
|
||||||
Text("Skip")
|
Text("Skip")
|
||||||
}
|
}
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onEnd,
|
onClick = onEnd,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
) {
|
) {
|
||||||
Text("End session", color = Color(0xFF9B8AA6))
|
Text("End session", color = Color(0xFF9B8AA6))
|
||||||
}
|
}
|
||||||
|
|
@ -203,12 +221,16 @@ private fun EmptySessionCard() {
|
||||||
"No active session",
|
"No active session",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = Color(0xFF261D2E)
|
color = Color(0xFF261D2E),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Go back to the category picker and spin the wheel to start.",
|
"Go back to the category picker and spin the wheel to start.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color(0xFF5A5060)
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,139 @@
|
||||||
# UI Responsive QA Review — Batch 8
|
# Batch 8 — Responsive Visual QA Code Review
|
||||||
|
|
||||||
**Date:** 2026-06-17
|
## Scope
|
||||||
**Package:** `app.closer`
|
Reviewed Compose UI files under `app/src/main/java/app/closer/ui/` for responsive layout issues:
|
||||||
**Project:** relationship-app (Android Jetpack Compose)
|
- 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 ✅
|
### `dates/DateBuilderScreen.kt`
|
||||||
- `home/HomeScreen.kt` — Responsive with proper navigation padding and scrollable content.
|
- Fixed header layout with proper weighting and truncation for value/budget text.
|
||||||
- `dates/DateMatchScreen.kt`, `dates/DateMatchesScreen.kt`, `dates/DateBuilderScreen.kt`, `dates/BucketListScreen.kt` — All use `safeDrawingPadding()` and `navigationBarsPadding()` correctly. Cards have adequate padding (17–20dp), `TextOverflow.Ellipsis` applied where needed.
|
- Added `weight(1f)` to duration chip rows so chips share space correctly.
|
||||||
|
- Increased chip touch target to meet 48dp minimum.
|
||||||
|
|
||||||
### Questions Screens ✅
|
### `dates/BucketListScreen.kt`
|
||||||
- `questions/DailyQuestionScreen.kt`, `QuestionCategoryScreen.kt`, `QuestionPackLibraryScreen.kt`, `QuestionThreadScreen.kt` — Consistent padding and spacing. `weight(1f)` used to prevent content from pushing buttons off-screen.
|
- Added `maxLines`/`overflow` to header title, subtitle, item title, and item description.
|
||||||
- Components reviewed:
|
- Added `navigationBarsPadding()` handling to the top-level content.
|
||||||
- `components/QuestionAnswerInput.kt` — All answer types (written, single/multi choice, scale, this-or-that) have proper touch targets (48–52dp) and maxLines/overflow handling.
|
- Made `LazyColumn` fill remaining space via `weight(1f)`.
|
||||||
- `components/QuestionHeader.kt` — Header uses card padding of 24dp horizontal/28dp vertical, appropriate for mobile.
|
- Added `horizontalScroll` to filter chip rows so they don't overflow on narrow screens.
|
||||||
- `components/QuestionDiscussionThread.kt` — Discussion bubble max width `260.dp`, proper padding and overflow on input text.
|
- Increased `FilterChip` and `CategoryChip` touch targets to 48dp minimum.
|
||||||
|
|
||||||
### Settings Screens ✅
|
### `components/PlaceholderScreen.kt`
|
||||||
- `settings/SettingsScreen.kt`, `AccountScreen.kt`, `PrivacyScreen.kt`, `SubscriptionScreen.kt` — All use `safeDrawingPadding()` + `navigationBarsPadding()`. Settings rows have 14dp vertical padding (touch target > 48dp total).
|
- Added `maxLines`/`overflow` to header title/description, panel title, "Ready" badge, and detail rows.
|
||||||
- `settings/RelationshipSettingsScreen.kt`, `DeleteAccountScreen.kt` — Danger screens have adequate button heights (52–56dp), proper alert dialog buttons.
|
|
||||||
|
|
||||||
### Pairing Screens ✅
|
### `settings/SettingsScreen.kt`
|
||||||
- `pairing/AcceptInviteScreen.kt`, `CreateInviteScreen.kt`, `InviteConfirmScreen.kt` — Invite code entry cards use `24.dp` horizontal padding on `fillMaxWidth()` cards. Buttons have `52.dp` height.
|
- Added text truncation to profile card name/email, partner card texts, and `SettingsRow` labels.
|
||||||
|
|
||||||
### Wheel Screens ✅
|
### `settings/AccountScreen.kt`
|
||||||
- `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 `48–52.dp`, touch targets sufficient.
|
- Added `weight(1f)` to profile card text column.
|
||||||
|
- Added text truncation to profile description and `AccountRow` labels.
|
||||||
|
|
||||||
### Auth & Onboarding ✅
|
### `settings/DeleteAccountScreen.kt`
|
||||||
- `auth/LoginScreen.kt`, `auth/SignUpScreen.kt`, `onboarding/CreateProfileScreen.kt` — Consistent vertical scroll with `safeDrawingPadding()`, `imePadding()`, and `padding(horizontal = 28.dp)`. Text fields have `52–56.dp` button heights.
|
- Added `heightIn(min = 48.dp)` to the acknowledgment checkbox row to meet minimum touch target.
|
||||||
|
|
||||||
### Answers Screens ✅
|
### `settings/RelationshipSettingsScreen.kt`
|
||||||
- `answers/AnswerHistoryScreen.kt`, `answers/AnswerRevealScreen.kt` — `LazyColumn` with proper padding (20dp horizontal). Cards have 17dp padding. Text has `maxLines = 2` with `TextOverflow.Ellipsis`.
|
- 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`.
|
### `questions/components/QuestionHeader.kt`
|
||||||
- **No bottom nav overlap** — All screens use `navigationBarsPadding()` or `safeDrawingPadding()` appropriately.
|
- Added `maxLines`/`overflow` to question text.
|
||||||
- **No cramped cards** — Card padding is consistent (16–28dp), rows have proper spacing (`Arrangement.spacedBy(8–14.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(8–20.dp)`, padding `12–28.dp` horizontal.
|
|
||||||
- **Touch targets ≥48dp** — All interactive elements meet minimum:
|
|
||||||
- Cards: Full-width (no issue)
|
|
||||||
- Buttons: `48–56.dp` height
|
|
||||||
- Icons/buttons in rows: `40–44.dp`, with `weight(1f)` ensuring adequate touch area
|
|
||||||
|
|
||||||
---
|
### `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.
|
||||||
|
|
||||||
```
|
### `questions/QuestionCategoryScreen.kt`
|
||||||
BUILD SUCCESSFUL in 376ms
|
- 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 |
|
### `wheel/SpinWheelScreen.kt`
|
||||||
|-------|--------|
|
- Added text truncation to headline and category pill.
|
||||||
| Text clipping | ✅ No issues |
|
- Increased all primary/outlined button heights to 56dp minimum.
|
||||||
| Bottom nav overlap | ✅ No issues |
|
|
||||||
| Cramped cards | ✅ No issues |
|
|
||||||
| Hierarchy problems | ✅ No issues |
|
|
||||||
| Inconsistent spacing | ✅ No issues |
|
|
||||||
| Touch targets | ✅ All ≥48dp |
|
|
||||||
| Build passes | ✅ SUCCESSFUL |
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue