feat: pending invite store, iOS subscription illustration, settings theme polish
This commit is contained in:
parent
5c85e0ee51
commit
da8ddf9ed3
|
|
@ -0,0 +1,41 @@
|
||||||
|
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 PendingInviteStore @Inject constructor(
|
||||||
|
@ApplicationContext context: Context
|
||||||
|
) {
|
||||||
|
private val prefs = SecurePreferencesFactory.encryptedSharedPreferences(context, PREFS_NAME)
|
||||||
|
|
||||||
|
data class PendingInvite(val code: String, val recoveryPhrase: String)
|
||||||
|
|
||||||
|
fun save(code: String, recoveryPhrase: String) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_CODE, code)
|
||||||
|
.putString(KEY_PHRASE, recoveryPhrase)
|
||||||
|
.putLong(KEY_CREATED_AT, System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(): PendingInvite? {
|
||||||
|
val code = prefs.getString(KEY_CODE, null) ?: return null
|
||||||
|
val phrase = prefs.getString(KEY_PHRASE, null) ?: return null
|
||||||
|
val age = System.currentTimeMillis() - prefs.getLong(KEY_CREATED_AT, 0L)
|
||||||
|
if (age > TTL_MS) { clear(); return null }
|
||||||
|
return PendingInvite(code, phrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() = prefs.edit().clear().apply()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "closer_pending_invite"
|
||||||
|
private const val KEY_CODE = "code"
|
||||||
|
private const val KEY_PHRASE = "phrase"
|
||||||
|
private const val KEY_CREATED_AT = "created_at"
|
||||||
|
private const val TTL_MS = 24L * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.PendingInviteStore
|
||||||
import app.closer.data.local.RecoveryPhraseStore
|
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
|
||||||
|
|
@ -16,10 +17,17 @@ 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
|
private val recoveryPhraseStore: RecoveryPhraseStore,
|
||||||
|
private val pendingInviteStore: PendingInviteStore
|
||||||
) : InviteRepository {
|
) : InviteRepository {
|
||||||
|
|
||||||
override suspend fun createInvite(): Result<CreateInviteResult> = runCatching {
|
override suspend fun createInvite(): Result<CreateInviteResult> = runCatching {
|
||||||
|
// Return the cached invite if it's still within the 24-hour window, avoiding redundant
|
||||||
|
// Cloud Function calls and the server-side rate limit (5 per hour).
|
||||||
|
pendingInviteStore.load()?.let { cached ->
|
||||||
|
return@runCatching CreateInviteResult(cached.code, cached.recoveryPhrase)
|
||||||
|
}
|
||||||
|
|
||||||
// Generate the keyset + phrase once. Only the code (and the per-code encrypted phrase)
|
// Generate the keyset + phrase once. Only the code (and the per-code encrypted phrase)
|
||||||
// changes on the rare collision retry — the keyset itself stays the same.
|
// changes on the rare collision retry — the keyset itself stays the same.
|
||||||
val setup = encryptionManager.setupForNewCouple()
|
val setup = encryptionManager.setupForNewCouple()
|
||||||
|
|
@ -30,6 +38,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)
|
||||||
|
pendingInviteStore.save(r.code, setup.recoveryPhrase)
|
||||||
recoveryPhraseStore.save(setup.recoveryPhrase)
|
recoveryPhraseStore.save(setup.recoveryPhrase)
|
||||||
return@runCatching CreateInviteResult(r.code, setup.recoveryPhrase)
|
return@runCatching CreateInviteResult(r.code, setup.recoveryPhrase)
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +58,7 @@ class InviteRepositoryImpl @Inject constructor(
|
||||||
runCatching { keyManager.decryptPhraseWithCode(code, it) }.getOrNull()
|
runCatching { keyManager.decryptPhraseWithCode(code, it) }.getOrNull()
|
||||||
}
|
}
|
||||||
phrase?.let { recoveryPhraseStore.save(it) }
|
phrase?.let { recoveryPhraseStore.save(it) }
|
||||||
|
pendingInviteStore.clear()
|
||||||
raw.copy(recoveryPhrase = phrase)
|
raw.copy(recoveryPhrase = phrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.PendingInviteStore
|
||||||
import app.closer.data.local.RecoveryPhraseStore
|
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
|
||||||
|
|
@ -51,7 +52,8 @@ class SettingsViewModel @Inject constructor(
|
||||||
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
|
private val recoveryPhraseStore: RecoveryPhraseStore,
|
||||||
|
private val pendingInviteStore: PendingInviteStore
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||||
|
|
@ -186,6 +188,7 @@ class SettingsViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
authRepository.signOut()
|
authRepository.signOut()
|
||||||
recoveryPhraseStore.clear()
|
recoveryPhraseStore.clear()
|
||||||
|
pendingInviteStore.clear()
|
||||||
_uiState.update { it.copy(isSigningOut = false, navigateTo = AppRoute.ONBOARDING) }
|
_uiState.update { it.copy(isSigningOut = false, navigateTo = AppRoute.ONBOARDING) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
|
|
@ -73,8 +73,10 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Upgrade to Premium") {
|
Button {
|
||||||
showPaywall = true
|
showPaywall = true
|
||||||
|
} label: {
|
||||||
|
PremiumSettingsCTA()
|
||||||
}
|
}
|
||||||
.disabled(isLoggedInAnonymously)
|
.disabled(isLoggedInAnonymously)
|
||||||
|
|
||||||
|
|
@ -635,15 +637,15 @@ struct PaywallView: View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: CloserSpacing.xxl) {
|
VStack(spacing: CloserSpacing.xxl) {
|
||||||
// Header
|
|
||||||
VStack(spacing: CloserSpacing.md) {
|
VStack(spacing: CloserSpacing.md) {
|
||||||
CloserIllustrationView(imageName: "illustration-couple-paywall", size: 180)
|
CloserIllustrationView(imageName: "illustration-couple-paywall", size: 184)
|
||||||
|
|
||||||
Text("Closer Premium")
|
Text("Go deeper together")
|
||||||
.font(CloserFont.title1)
|
.font(CloserFont.title1)
|
||||||
.foregroundColor(.closerText)
|
.foregroundColor(.closerText)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
Text("Deepen your connection with exclusive features")
|
|
||||||
|
Text("Unlock everything Closer has built for couples.")
|
||||||
.font(CloserFont.callout)
|
.font(CloserFont.callout)
|
||||||
.foregroundColor(.closerTextSecondary)
|
.foregroundColor(.closerTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
@ -793,6 +795,37 @@ struct PaywallView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Premium Settings CTA
|
||||||
|
|
||||||
|
struct PremiumSettingsCTA: View {
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: CloserSpacing.md) {
|
||||||
|
Image("illustration-couple-subscription")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 68, height: 68)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: CloserRadius.large, style: .continuous))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: CloserSpacing.xs) {
|
||||||
|
Text("Upgrade to Premium")
|
||||||
|
.font(CloserFont.headline)
|
||||||
|
.foregroundColor(.closerText)
|
||||||
|
Text("One subscription for both partners.")
|
||||||
|
.font(CloserFont.caption)
|
||||||
|
.foregroundColor(.closerTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundColor(.closerTextSecondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, CloserSpacing.xs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Premium Feature Row
|
// MARK: - Premium Feature Row
|
||||||
|
|
||||||
struct PremiumFeatureRow: View {
|
struct PremiumFeatureRow: View {
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ enum CloserFont {
|
||||||
static let subheadline = Font.system(size: 15, weight: .regular)
|
static let subheadline = Font.system(size: 15, weight: .regular)
|
||||||
static let footnote = Font.system(size: 13, weight: .regular)
|
static let footnote = Font.system(size: 13, weight: .regular)
|
||||||
static let caption = Font.system(size: 12, weight: .regular)
|
static let caption = Font.system(size: 12, weight: .regular)
|
||||||
|
static let caption2 = Font.system(size: 11, weight: .regular)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Spacing
|
// MARK: - Spacing
|
||||||
|
|
@ -172,4 +173,4 @@ extension View {
|
||||||
.foregroundColor(.closerText)
|
.foregroundColor(.closerText)
|
||||||
.padding(.bottom, CloserSpacing.sm)
|
.padding(.bottom, CloserSpacing.sm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue