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