feat: paywall illustrations, subscription polish, recovery store, settings cleanup

This commit is contained in:
null 2026-06-21 16:27:55 -05:00
parent 991b70f405
commit 5c85e0ee51
11 changed files with 156 additions and 61 deletions

View File

@ -148,6 +148,9 @@ dependencies {
// Image loading // Image loading
implementation("io.coil-kt:coil-compose:2.7.0") implementation("io.coil-kt:coil-compose:2.7.0")
// Biometric auth — used in Settings to gate recovery phrase reveal
implementation("androidx.biometric:biometric:1.1.0")
// Google Sign-In via Credential Manager // Google Sign-In via Credential Manager
implementation("androidx.credentials:credentials:1.3.0") implementation("androidx.credentials:credentials:1.3.0")
implementation("androidx.credentials:credentials-play-services-auth:1.3.0") implementation("androidx.credentials:credentials-play-services-auth:1.3.0")

View File

@ -0,0 +1,22 @@
package app.closer.data.local
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecoveryPhraseStore @Inject constructor(
@ApplicationContext context: Context
) {
private val prefs = SecurePreferencesFactory.encryptedSharedPreferences(context, PREFS_NAME)
fun save(phrase: String) = prefs.edit().putString(KEY_PHRASE, phrase).apply()
fun load(): String? = prefs.getString(KEY_PHRASE, null)
fun clear() = prefs.edit().remove(KEY_PHRASE).apply()
companion object {
private const val PREFS_NAME = "closer_recovery"
private const val KEY_PHRASE = "recovery_phrase"
}
}

View File

@ -2,6 +2,7 @@ package app.closer.data.repository
import app.closer.crypto.CoupleEncryptionManager import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.RecoveryKeyManager import app.closer.crypto.RecoveryKeyManager
import app.closer.data.local.RecoveryPhraseStore
import app.closer.data.remote.FirestoreInviteDataSource import app.closer.data.remote.FirestoreInviteDataSource
import app.closer.domain.repository.AcceptInviteResult import app.closer.domain.repository.AcceptInviteResult
import app.closer.domain.repository.CreateInviteResult import app.closer.domain.repository.CreateInviteResult
@ -14,7 +15,8 @@ import kotlin.random.Random
class InviteRepositoryImpl @Inject constructor( class InviteRepositoryImpl @Inject constructor(
private val dataSource: FirestoreInviteDataSource, private val dataSource: FirestoreInviteDataSource,
private val encryptionManager: CoupleEncryptionManager, private val encryptionManager: CoupleEncryptionManager,
private val keyManager: RecoveryKeyManager private val keyManager: RecoveryKeyManager,
private val recoveryPhraseStore: RecoveryPhraseStore
) : InviteRepository { ) : InviteRepository {
override suspend fun createInvite(): Result<CreateInviteResult> = runCatching { override suspend fun createInvite(): Result<CreateInviteResult> = runCatching {
@ -28,6 +30,7 @@ class InviteRepositoryImpl @Inject constructor(
val response = runCatching { dataSource.createInvite(code, setup.wrapped, encryptedPhrase) } val response = runCatching { dataSource.createInvite(code, setup.wrapped, encryptedPhrase) }
response.onSuccess { r -> response.onSuccess { r ->
encryptionManager.storeInviteSetup(r.code, setup) encryptionManager.storeInviteSetup(r.code, setup)
recoveryPhraseStore.save(setup.recoveryPhrase)
return@runCatching CreateInviteResult(r.code, setup.recoveryPhrase) return@runCatching CreateInviteResult(r.code, setup.recoveryPhrase)
} }
response.onFailure { e -> response.onFailure { e ->
@ -45,6 +48,7 @@ class InviteRepositoryImpl @Inject constructor(
val phrase = raw.recoveryPhrase?.let { val phrase = raw.recoveryPhrase?.let {
runCatching { keyManager.decryptPhraseWithCode(code, it) }.getOrNull() runCatching { keyManager.decryptPhraseWithCode(code, it) }.getOrNull()
} }
phrase?.let { recoveryPhraseStore.save(it) }
raw.copy(recoveryPhrase = phrase) raw.copy(recoveryPhrase = phrase)
} }

View File

@ -5,6 +5,7 @@ import app.closer.ui.theme.closerBackgroundBrush
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import androidx.compose.foundation.Image
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
@ -27,7 +28,6 @@ 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.material.icons.filled.Close
import androidx.compose.material.icons.filled.Star
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
@ -52,18 +52,20 @@ 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role 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 androidx.hilt.navigation.compose.hiltViewModel
import app.closer.R
import app.closer.core.navigation.ExternalLinks import app.closer.core.navigation.ExternalLinks
import app.closer.domain.repository.BillingState import app.closer.domain.repository.BillingState
import app.closer.ui.components.ErrorState import app.closer.ui.components.ErrorState
import app.closer.ui.components.LoadingState import app.closer.ui.components.LoadingState
import app.closer.ui.components.StatusGlyph
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import com.revenuecat.purchases.Package import com.revenuecat.purchases.Package
@ -172,12 +174,13 @@ private fun HeaderSection(
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
StatusGlyph( Image(
icon = Icons.Filled.Star, painter = painterResource(R.drawable.illustration_couple_paywall),
tint = CloserPalette.PurpleDeep, contentDescription = null,
container = CloserPalette.PurpleGlow, contentScale = ContentScale.Crop,
size = 58.dp, modifier = Modifier
iconSize = 28.dp .size(width = 96.dp, height = 112.dp)
.clip(RoundedCornerShape(22.dp))
) )
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(

View File

@ -47,6 +47,10 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -58,12 +62,13 @@ import androidx.compose.ui.Alignment
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.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import android.content.Context
import androidx.compose.ui.platform.LocalContext
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.core.navigation.ExternalLinks import app.closer.core.navigation.ExternalLinks
import app.closer.domain.model.OutcomeDay import app.closer.domain.model.OutcomeDay
@ -183,6 +188,29 @@ fun SettingsScreen(
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
val context = LocalContext.current
fun launchBiometric() {
val activity = context as? FragmentActivity ?: return
BiometricPrompt(
activity,
ContextCompat.getMainExecutor(context),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
viewModel.revealRecoveryPhrase()
}
}
).authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("Recovery phrase")
.setDescription("Authenticate to view your recovery phrase")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
.build()
)
}
LaunchedEffect(state.navigateTo) { LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() } state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
@ -265,6 +293,32 @@ fun SettingsScreen(
} }
} }
state.recoveryPhrase?.let { phrase ->
AlertDialog(
onDismissRequest = viewModel::hideRecoveryPhrase,
title = { Text("Recovery phrase", color = SettingsInk) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Write this down and keep it somewhere safe. You'll need it to restore your encrypted history if both devices are lost.",
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted
)
Text(
phrase,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = SettingsInk
)
}
},
confirmButton = {
TextButton(onClick = viewModel::hideRecoveryPhrase) { Text("Done") }
},
containerColor = SettingsSoft
)
}
LaunchedEffect(state.outcomeSubmitSuccess) { LaunchedEffect(state.outcomeSubmitSuccess) {
if (state.outcomeSubmitSuccess) { if (state.outcomeSubmitSuccess) {
snackbar.showSnackbar("Check-in saved") snackbar.showSnackbar("Check-in saved")
@ -445,6 +499,28 @@ fun SettingsScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Security section — visible only when a recovery phrase is stored on-device
if (state.hasRecoveryPhrase) {
Text(
text = "Security",
style = MaterialTheme.typography.labelLarge,
color = SettingsMuted,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = SettingsCard)
) {
SettingsRow(
icon = Icons.Filled.Lock,
label = "Recovery phrase",
onClick = { launchBiometric() }
)
}
Spacer(Modifier.height(8.dp))
}
// Legal // Legal
Text( Text(
text = "Legal", text = "Legal",
@ -458,7 +534,6 @@ fun SettingsScreen(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = SettingsCard) colors = CardDefaults.cardColors(containerColor = SettingsCard)
) { ) {
val context = LocalContext.current
Column { Column {
SettingsLegalRow( SettingsLegalRow(
label = "Privacy Policy", label = "Privacy Policy",

View File

@ -8,6 +8,7 @@ import app.closer.domain.model.Outcome
import app.closer.domain.model.OutcomeDay import app.closer.domain.model.OutcomeDay
import app.closer.domain.model.OutcomeDayKey import app.closer.domain.model.OutcomeDayKey
import app.closer.domain.model.OutcomeScores import app.closer.domain.model.OutcomeScores
import app.closer.data.local.RecoveryPhraseStore
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.OutcomeRepository import app.closer.domain.repository.OutcomeRepository
@ -38,7 +39,9 @@ data class SettingsUiState(
val outcomeBaselineDialogDue: Boolean = false, val outcomeBaselineDialogDue: Boolean = false,
val outcomeFollowUpDay: OutcomeDay? = null, val outcomeFollowUpDay: OutcomeDay? = null,
val outcomeSubmitSuccess: Boolean = false, val outcomeSubmitSuccess: Boolean = false,
val outcomeError: String? = null val outcomeError: String? = null,
val hasRecoveryPhrase: Boolean = false,
val recoveryPhrase: String? = null
) )
@HiltViewModel @HiltViewModel
@ -47,7 +50,8 @@ class SettingsViewModel @Inject constructor(
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val outcomeRepository: OutcomeRepository private val outcomeRepository: OutcomeRepository,
private val recoveryPhraseStore: RecoveryPhraseStore
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState()) private val _uiState = MutableStateFlow(SettingsUiState())
@ -107,7 +111,8 @@ class SettingsViewModel @Inject constructor(
partnerName = partnerName, partnerName = partnerName,
isPaired = couple != null, isPaired = couple != null,
outcomeBaselineDialogDue = outcomeBaselineDialogDue, outcomeBaselineDialogDue = outcomeBaselineDialogDue,
outcomeFollowUpDay = followUpDay outcomeFollowUpDay = followUpDay,
hasRecoveryPhrase = recoveryPhraseStore.load() != null
) )
} }
} }
@ -169,10 +174,18 @@ class SettingsViewModel @Inject constructor(
return if (outcomes.any { it.dayKey == due.first.key }) null else due.first return if (outcomes.any { it.dayKey == due.first.key }) null else due.first
} }
fun revealRecoveryPhrase() {
val phrase = recoveryPhraseStore.load() ?: return
_uiState.update { it.copy(recoveryPhrase = phrase) }
}
fun hideRecoveryPhrase() = _uiState.update { it.copy(recoveryPhrase = null) }
fun signOut() { fun signOut() {
_uiState.update { it.copy(isSigningOut = true) } _uiState.update { it.copy(isSigningOut = true) }
viewModelScope.launch { viewModelScope.launch {
authRepository.signOut() authRepository.signOut()
recoveryPhraseStore.clear()
_uiState.update { it.copy(isSigningOut = false, navigateTo = AppRoute.ONBOARDING) } _uiState.update { it.copy(isSigningOut = false, navigateTo = AppRoute.ONBOARDING) }
} }
} }

View File

@ -1,6 +1,7 @@
package app.closer.ui.settings package app.closer.ui.settings
import android.util.Log import android.util.Log
import androidx.compose.foundation.Image
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.Column import androidx.compose.foundation.layout.Column
@ -17,15 +18,11 @@ import androidx.compose.foundation.rememberScrollState
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@ -33,8 +30,6 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -42,12 +37,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.R
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker import app.closer.core.billing.EntitlementChecker
@ -140,7 +139,6 @@ private fun CustomerInfo.renewalLabel(): String? {
return "Renews ${fmt.format(expiry)}" return "Renews ${fmt.format(expiry)}"
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SubscriptionScreen( fun SubscriptionScreen(
onNavigate: (String) -> Unit = {}, onNavigate: (String) -> Unit = {},
@ -166,22 +164,7 @@ fun SubscriptionScreen(
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbar) }, snackbarHost = { SnackbarHost(snackbar) },
containerColor = Color.Transparent, containerColor = Color.Transparent,
modifier = Modifier.background(closerBackgroundBrush()), modifier = Modifier.background(closerBackgroundBrush())
topBar = {
TopAppBar(
title = { Text("Subscription", color = SettingsInk) },
navigationIcon = {
IconButton(onClick = { onNavigate("back") }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = SettingsInk
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding -> ) { padding ->
if (state.isLoading) { if (state.isLoading) {
LoadingState(modifier = Modifier.fillMaxSize().padding(padding)) LoadingState(modifier = Modifier.fillMaxSize().padding(padding))
@ -230,20 +213,7 @@ private fun PremiumContent(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Surface( SubscriptionHeroImage()
shape = RoundedCornerShape(16.dp),
color = CloserPalette.PurpleDeep,
modifier = Modifier.size(56.dp)
) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.padding(14.dp)
.size(28.dp)
)
}
Text( Text(
text = "You're Premium", text = "You're Premium",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
@ -348,12 +318,7 @@ private fun FreeContent(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) { ) {
Icon( SubscriptionHeroImage()
imageVector = Icons.Filled.Star,
contentDescription = null,
tint = CloserPalette.PurpleDeep,
modifier = Modifier.size(40.dp)
)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text( Text(
text = "Unlock Premium", text = "Unlock Premium",
@ -427,3 +392,15 @@ private fun FreeContent(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
} }
@Composable
private fun SubscriptionHeroImage(modifier: Modifier = Modifier) {
Image(
painter = painterResource(R.drawable.illustration_couple_subscription),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier
.size(width = 188.dp, height = 220.dp)
.clip(RoundedCornerShape(28.dp))
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -637,9 +637,7 @@ struct PaywallView: View {
VStack(spacing: CloserSpacing.xxl) { VStack(spacing: CloserSpacing.xxl) {
// Header // Header
VStack(spacing: CloserSpacing.md) { VStack(spacing: CloserSpacing.md) {
Image(systemName: "sparkles") CloserIllustrationView(imageName: "illustration-couple-paywall", size: 180)
.font(.system(size: 48))
.foregroundColor(.closerGold)
Text("Closer Premium") Text("Closer Premium")
.font(CloserFont.title1) .font(CloserFont.title1)