feat: paywall illustrations, subscription polish, recovery store, settings cleanup
This commit is contained in:
parent
991b70f405
commit
5c85e0ee51
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue