feat: pending invite store, iOS subscription illustration, settings theme polish
This commit is contained in:
parent
b19fc0934c
commit
b720f0cf14
|
|
@ -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.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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue