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

View File

@ -5,6 +5,7 @@ import app.closer.ui.theme.closerBackgroundBrush
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.R
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 app.closer.ui.components.StatusGlyph
import app.closer.ui.theme.CloserPalette
import com.revenuecat.purchases.Package
@ -172,12 +174,13 @@ private fun HeaderSection(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top
) {
StatusGlyph(
icon = Icons.Filled.Star,
tint = CloserPalette.PurpleDeep,
container = CloserPalette.PurpleGlow,
size = 58.dp,
iconSize = 28.dp
Image(
painter = painterResource(R.drawable.illustration_couple_paywall),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 96.dp, height = 112.dp)
.clip(RoundedCornerShape(22.dp))
)
Column(modifier = Modifier.weight(1f)) {
Text(

View File

@ -47,6 +47,10 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -58,12 +62,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
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.ExternalLinks
import app.closer.domain.model.OutcomeDay
@ -183,6 +188,29 @@ fun SettingsScreen(
) {
val state by viewModel.uiState.collectAsState()
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) {
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) {
if (state.outcomeSubmitSuccess) {
snackbar.showSnackbar("Check-in saved")
@ -445,6 +499,28 @@ fun SettingsScreen(
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
Text(
text = "Legal",
@ -458,7 +534,6 @@ fun SettingsScreen(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = SettingsCard)
) {
val context = LocalContext.current
Column {
SettingsLegalRow(
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.OutcomeDayKey
import app.closer.domain.model.OutcomeScores
import app.closer.data.local.RecoveryPhraseStore
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.OutcomeRepository
@ -38,7 +39,9 @@ data class SettingsUiState(
val outcomeBaselineDialogDue: Boolean = false,
val outcomeFollowUpDay: OutcomeDay? = null,
val outcomeSubmitSuccess: Boolean = false,
val outcomeError: String? = null
val outcomeError: String? = null,
val hasRecoveryPhrase: Boolean = false,
val recoveryPhrase: String? = null
)
@HiltViewModel
@ -47,7 +50,8 @@ class SettingsViewModel @Inject constructor(
private val userRepository: UserRepository,
private val coupleRepository: CoupleRepository,
private val settingsRepository: SettingsRepository,
private val outcomeRepository: OutcomeRepository
private val outcomeRepository: OutcomeRepository,
private val recoveryPhraseStore: RecoveryPhraseStore
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
@ -107,7 +111,8 @@ class SettingsViewModel @Inject constructor(
partnerName = partnerName,
isPaired = couple != null,
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
}
fun revealRecoveryPhrase() {
val phrase = recoveryPhraseStore.load() ?: return
_uiState.update { it.copy(recoveryPhrase = phrase) }
}
fun hideRecoveryPhrase() = _uiState.update { it.copy(recoveryPhrase = null) }
fun signOut() {
_uiState.update { it.copy(isSigningOut = true) }
viewModelScope.launch {
authRepository.signOut()
recoveryPhraseStore.clear()
_uiState.update { it.copy(isSigningOut = false, navigateTo = AppRoute.ONBOARDING) }
}
}

View File

@ -1,6 +1,7 @@
package app.closer.ui.settings
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.verticalScroll
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.filled.Check
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
@ -33,8 +30,6 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -42,12 +37,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.R
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
@ -140,7 +139,6 @@ private fun CustomerInfo.renewalLabel(): String? {
return "Renews ${fmt.format(expiry)}"
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SubscriptionScreen(
onNavigate: (String) -> Unit = {},
@ -166,22 +164,7 @@ fun SubscriptionScreen(
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
containerColor = Color.Transparent,
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)
)
}
modifier = Modifier.background(closerBackgroundBrush())
) { padding ->
if (state.isLoading) {
LoadingState(modifier = Modifier.fillMaxSize().padding(padding))
@ -230,20 +213,7 @@ private fun PremiumContent(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
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)
)
}
SubscriptionHeroImage()
Text(
text = "You're Premium",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
@ -348,12 +318,7 @@ private fun FreeContent(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null,
tint = CloserPalette.PurpleDeep,
modifier = Modifier.size(40.dp)
)
SubscriptionHeroImage()
Spacer(Modifier.height(10.dp))
Text(
text = "Unlock Premium",
@ -427,3 +392,15 @@ private fun FreeContent(
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) {
// Header
VStack(spacing: CloserSpacing.md) {
Image(systemName: "sparkles")
.font(.system(size: 48))
.foregroundColor(.closerGold)
CloserIllustrationView(imageName: "illustration-couple-paywall", size: 180)
Text("Closer Premium")
.font(CloserFont.title1)