feat: pending invite store, iOS subscription illustration, settings theme polish

This commit is contained in:
null 2026-06-21 17:04:40 -05:00
parent b19fc0934c
commit b720f0cf14
6 changed files with 98 additions and 10 deletions

View File

@ -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
}
}

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.PendingInviteStore
import app.closer.data.local.RecoveryPhraseStore
import app.closer.data.remote.FirestoreInviteDataSource
import app.closer.domain.repository.AcceptInviteResult
@ -16,10 +17,17 @@ class InviteRepositoryImpl @Inject constructor(
private val dataSource: FirestoreInviteDataSource,
private val encryptionManager: CoupleEncryptionManager,
private val keyManager: RecoveryKeyManager,
private val recoveryPhraseStore: RecoveryPhraseStore
private val recoveryPhraseStore: RecoveryPhraseStore,
private val pendingInviteStore: PendingInviteStore
) : InviteRepository {
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)
// changes on the rare collision retry — the keyset itself stays the same.
val setup = encryptionManager.setupForNewCouple()
@ -30,6 +38,7 @@ class InviteRepositoryImpl @Inject constructor(
val response = runCatching { dataSource.createInvite(code, setup.wrapped, encryptedPhrase) }
response.onSuccess { r ->
encryptionManager.storeInviteSetup(r.code, setup)
pendingInviteStore.save(r.code, setup.recoveryPhrase)
recoveryPhraseStore.save(setup.recoveryPhrase)
return@runCatching CreateInviteResult(r.code, setup.recoveryPhrase)
}
@ -49,6 +58,7 @@ class InviteRepositoryImpl @Inject constructor(
runCatching { keyManager.decryptPhraseWithCode(code, it) }.getOrNull()
}
phrase?.let { recoveryPhraseStore.save(it) }
pendingInviteStore.clear()
raw.copy(recoveryPhrase = phrase)
}

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.PendingInviteStore
import app.closer.data.local.RecoveryPhraseStore
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
@ -51,7 +52,8 @@ class SettingsViewModel @Inject constructor(
private val coupleRepository: CoupleRepository,
private val settingsRepository: SettingsRepository,
private val outcomeRepository: OutcomeRepository,
private val recoveryPhraseStore: RecoveryPhraseStore
private val recoveryPhraseStore: RecoveryPhraseStore,
private val pendingInviteStore: PendingInviteStore
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
@ -186,6 +188,7 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch {
authRepository.signOut()
recoveryPhraseStore.clear()
pendingInviteStore.clear()
_uiState.update { it.copy(isSigningOut = false, navigateTo = AppRoute.ONBOARDING) }
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -73,8 +73,10 @@ struct SettingsView: View {
}
}
Button("Upgrade to Premium") {
Button {
showPaywall = true
} label: {
PremiumSettingsCTA()
}
.disabled(isLoggedInAnonymously)
@ -635,15 +637,15 @@ struct PaywallView: View {
NavigationStack {
ScrollView {
VStack(spacing: CloserSpacing.xxl) {
// Header
VStack(spacing: CloserSpacing.md) {
CloserIllustrationView(imageName: "illustration-couple-paywall", size: 180)
Text("Closer Premium")
CloserIllustrationView(imageName: "illustration-couple-paywall", size: 184)
Text("Go deeper together")
.font(CloserFont.title1)
.foregroundColor(.closerText)
Text("Deepen your connection with exclusive features")
.multilineTextAlignment(.center)
Text("Unlock everything Closer has built for couples.")
.font(CloserFont.callout)
.foregroundColor(.closerTextSecondary)
.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
struct PremiumFeatureRow: View {

View File

@ -66,6 +66,7 @@ enum CloserFont {
static let subheadline = Font.system(size: 15, weight: .regular)
static let footnote = Font.system(size: 13, weight: .regular)
static let caption = Font.system(size: 12, weight: .regular)
static let caption2 = Font.system(size: 11, weight: .regular)
}
// MARK: - Spacing
@ -172,4 +173,4 @@ extension View {
.foregroundColor(.closerText)
.padding(.bottom, CloserSpacing.sm)
}
}
}