diff --git a/app/src/main/java/app/closer/data/local/PendingInviteStore.kt b/app/src/main/java/app/closer/data/local/PendingInviteStore.kt new file mode 100644 index 00000000..407a4a5b --- /dev/null +++ b/app/src/main/java/app/closer/data/local/PendingInviteStore.kt @@ -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 + } +} 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 ef2fa215..d0ddaadf 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.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 = 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) } 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 6b24dc09..bc39ea3c 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.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) } } } diff --git a/iphone/Closer/Resources/illustration-couple-subscription.png b/iphone/Closer/Resources/illustration-couple-subscription.png new file mode 100644 index 00000000..a6988611 Binary files /dev/null and b/iphone/Closer/Resources/illustration-couple-subscription.png differ diff --git a/iphone/Closer/Settings/SettingsViews.swift b/iphone/Closer/Settings/SettingsViews.swift index 8cefcb30..110d713f 100644 --- a/iphone/Closer/Settings/SettingsViews.swift +++ b/iphone/Closer/Settings/SettingsViews.swift @@ -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 { diff --git a/iphone/Closer/Theme/CloserTheme.swift b/iphone/Closer/Theme/CloserTheme.swift index 721271fc..36bdce9e 100644 --- a/iphone/Closer/Theme/CloserTheme.swift +++ b/iphone/Closer/Theme/CloserTheme.swift @@ -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) } -} \ No newline at end of file +}