diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1553fec5..76b84bb4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/app/closer/data/local/RecoveryPhraseStore.kt b/app/src/main/java/app/closer/data/local/RecoveryPhraseStore.kt new file mode 100644 index 00000000..64230248 --- /dev/null +++ b/app/src/main/java/app/closer/data/local/RecoveryPhraseStore.kt @@ -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" + } +} diff --git a/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt index 53106761..ef2fa215 100644 --- a/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt @@ -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 = 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) } diff --git a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt index a19b512f..734aee5f 100644 --- a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt +++ b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt @@ -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( diff --git a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt index 83d87fac..b08cdc98 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt @@ -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", diff --git a/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt index 3f446661..6b24dc09 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt @@ -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) } } } diff --git a/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt b/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt index d1dea4ce..d79e9f4d 100644 --- a/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt @@ -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)) + ) +} diff --git a/app/src/main/res/drawable-nodpi/illustration_couple_paywall.png b/app/src/main/res/drawable-nodpi/illustration_couple_paywall.png new file mode 100644 index 00000000..4ff490e5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/illustration_couple_paywall.png differ diff --git a/app/src/main/res/drawable-nodpi/illustration_couple_subscription.png b/app/src/main/res/drawable-nodpi/illustration_couple_subscription.png new file mode 100644 index 00000000..a6988611 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/illustration_couple_subscription.png differ diff --git a/iphone/Closer/Resources/illustration-couple-paywall.png b/iphone/Closer/Resources/illustration-couple-paywall.png new file mode 100644 index 00000000..4ff490e5 Binary files /dev/null and b/iphone/Closer/Resources/illustration-couple-paywall.png differ diff --git a/iphone/Closer/Settings/SettingsViews.swift b/iphone/Closer/Settings/SettingsViews.swift index 398a8e1f..8cefcb30 100644 --- a/iphone/Closer/Settings/SettingsViews.swift +++ b/iphone/Closer/Settings/SettingsViews.swift @@ -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)