From f47c8e2b64124e2fd56b51952b60318cebca12a1 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 25 Jun 2026 16:00:58 -0500 Subject: [PATCH] =?UTF-8?q?feat(qa):=20clear=20Future.md=20backlog=20?= =?UTF-8?q?=E2=80=94=20inclusive=20gender,=20turn-aware=20copy,=20push=20b?= =?UTF-8?q?udgets,=20paywall=20polish,=20auth=20rotator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the QA improvement backlog from Future.md: - Inclusive sex/gender options (Female/Male/Non-binary/Prefer not to say) in onboarding + Edit Profile; honest copy (Desire Sync is already gender-neutral, no tailoring fallback needed). - Turn-aware Home "waiting to play" copy ("Your turn to play."). - Partner-action/results pushes exempt from the weekly promotional rate-limit ceiling (NotificationRateLimiter); reminders still bound by it. Tests updated. - Suppress the redundant results / "partner finished" push when the recipient is already on that game's screen — new ActiveGameSessionMonitor (mirrors ActiveThreadMonitor), wired into the This or That / How Well / Desire Sync VMs + Wheel results; guarded in PartnerNotificationManager. - Paywall: retry-with-backoff, offline-aware error copy, Continue hidden until plans load. - Privacy-message rotator on Sign up + Forgot password (Login already had it). iOS illustrations were already wired into the Android empty states (no change needed). Brand-glyph G-set remains in Future.md — blocked on generated art. Co-Authored-By: Claude Opus 4.8 --- Future.md | 47 +++++++-------- .../java/app/closer/di/NotificationModule.kt | 6 +- .../notifications/ActiveGameSessionMonitor.kt | 29 +++++++++ .../notifications/NotificationRateLimiter.kt | 13 +++- .../PartnerNotificationManager.kt | 11 +++- .../closer/ui/auth/ForgotPasswordScreen.kt | 3 + .../java/app/closer/ui/auth/SignUpScreen.kt | 4 ++ .../closer/ui/desiresync/DesireSyncScreen.kt | 10 +++- .../java/app/closer/ui/home/HomeViewModel.kt | 6 +- .../app/closer/ui/howwell/HowWellScreen.kt | 10 +++- .../ui/onboarding/CreateProfileScreen.kt | 16 ++++- .../ui/onboarding/CreateProfileViewModel.kt | 4 +- .../app/closer/ui/paywall/PaywallScreen.kt | 38 +++++++----- .../app/closer/ui/paywall/PaywallViewModel.kt | 60 +++++++++++++++---- .../closer/ui/settings/EditProfileScreen.kt | 53 ++++++++++------ .../ui/settings/EditProfileViewModel.kt | 2 +- .../closer/ui/thisorthat/ThisOrThatScreen.kt | 10 +++- .../closer/ui/wheel/WheelCompleteScreen.kt | 9 ++- .../NotificationRateLimiterTest.kt | 52 +++++++++------- .../PartnerNotificationManagerTest.kt | 9 ++- 20 files changed, 277 insertions(+), 115 deletions(-) create mode 100644 app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt diff --git a/Future.md b/Future.md index 10827589..f99bf44f 100644 --- a/Future.md +++ b/Future.md @@ -7,32 +7,25 @@ Non-blocking ideas: things that work today but could be better, plus feature ide Improvement & feature ideas surfaced while QA-testing as a consumer (each works today — none are defects). -- **Inclusive sex/gender options in onboarding.** Profile step 2 ("What's your sex?") offers only **Female / Male**, - and it drives Desire Sync question tailoring. For a couples app, consider adding a non-binary / "prefer to - self-describe" / "prefer not to say" option (with a sensible tailoring fallback). *Prompted by:* Pass G sign-up flow. -- **Home "waiting to play" copy doesn't say whose turn it is.** Both partners' Home shows "**Your** partner is waiting - to play" for the *same* session, so each thinks the other is mid-game. Consider turn-aware copy ("Your turn — - finish your part" vs "Waiting on {partner}"). *Prompted by:* B-002 (resume is fixed; this is the copy nuance). -- **Exempt high-value/user-initiated pushes from the promotional rate limit.** Game-completion ("results ready") and - similar pushes share the same 20/day, 100/week cap as reminders/re-engagement. During normal heavy use a legitimate - results-ready push was suppressed. Consider a separate (higher/uncapped) budget for partner-action/results pushes vs - promotional reminders. *Prompted by:* Round 5 E-003 results-ready verification. -- **Suppress the redundant "tap to see results" push when the recipient is already on the results screen.** When both - partners finish a game at nearly the same time, both are already viewing results yet both still receive the - results-ready push. Mirror the existing chat-message guard (`activeThreadMonitor`) for the active game/results screen. - *Prompted by:* Round 5 results-ready test. -- **Friendlier paywall empty/error state.** A-OBS fixed the raw SDK error leak; as a follow-up, when plans genuinely - can't load, consider a retry-with-backoff + an offline-aware message, and hide the disabled "Continue" until plans - load. *Prompted by:* A-OBS (env had no RevenueCat sandbox, but the state is worth polishing for real failures). -- **Wire existing iOS illustrations into Android (brand uplift, low effort).** `docs/brand/asset-system.md` notes the - couple illustrations exist on iOS but several Android screens don't show them yet (answer history, invite, daily- - question, together-empty, partner-activation empty states). Mirroring the PNGs into `drawable-nodpi/` and using them - would noticeably warm up empty states with no new art needed. *Prompted by:* Pass H branding review. -- **Consistent brand glyphs across game cards + waiting/notification surfaces.** Game cards (Play hub), the - WaitingForPartner screen, and notifications mix Material icons with brand art. A small custom glyph set - (heart-of-two-halves, paired/sealed card, daily card, capsule, date-card, quiet-hours moon) used consistently would - strengthen identity. See `ClaudeBrandingReview.md` "G-set". *Prompted by:* Pass H branding review. -- **Rotating privacy messages on more entry surfaces.** The approved privacy-message rotation is a strong, on-brand - reassurance; consider surfacing it on the sign-up/login/forgot-password screens (currently plain forms). *Prompted by:* Pass H. +- **Consistent brand glyphs across game cards + waiting/notification surfaces.** _(Blocked: needs the + generated G-set art — image generation is the user's step per `ClaudeBrandingReview.md`.)_ Game cards + (Play hub), the WaitingForPartner screen, and notifications mix Material icons with brand art. A small + custom glyph set (the C-heart-keyhole mark, paired/sealed card, daily card, capsule, date-card, + quiet-hours moon) used consistently would strengthen identity. Generate the G-set, drop the assets in, + then wire them in. *Prompted by:* Pass H branding review. > Artwork to generate (ChatGPT prompts, house-style-matched) lives in `ClaudeBrandingReview.md`, not here. + + diff --git a/app/src/main/java/app/closer/di/NotificationModule.kt b/app/src/main/java/app/closer/di/NotificationModule.kt index 1e771e27..40db5679 100644 --- a/app/src/main/java/app/closer/di/NotificationModule.kt +++ b/app/src/main/java/app/closer/di/NotificationModule.kt @@ -38,12 +38,14 @@ object NotificationModule { settingsRepository: SettingsRepository, quietHoursManager: QuietHoursManager, rateLimiter: NotificationRateLimiter, - activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor + activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor, + activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor ): PartnerNotificationManager = PartnerNotificationManager( context, settingsRepository, quietHoursManager, rateLimiter, - activeThreadMonitor + activeThreadMonitor, + activeGameSessionMonitor ) } diff --git a/app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt b/app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt new file mode 100644 index 00000000..990db07a --- /dev/null +++ b/app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt @@ -0,0 +1,29 @@ +package app.closer.notifications + +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Tracks which game session (by sessionId) the user is currently viewing — set on entering a + * game / results screen, cleared on leaving — so a redundant "your results are ready" or + * "your partner finished their part" push can be suppressed while they are already on that + * session live (e.g. both partners finish at nearly the same time and are both on the results + * screen). + * + * Mirrors [ActiveThreadMonitor]. Foreground-only by nature: when the app is backgrounded the OS + * auto-displays the push and this monitor isn't consulted — which is the desired behaviour. + */ +@Singleton +class ActiveGameSessionMonitor @Inject constructor() { + @Volatile + var activeSessionId: String? = null + private set + + fun enter(sessionId: String) { + if (sessionId.isNotBlank()) activeSessionId = sessionId + } + + fun leave(sessionId: String) { + if (activeSessionId == sessionId) activeSessionId = null + } +} diff --git a/app/src/main/java/app/closer/notifications/NotificationRateLimiter.kt b/app/src/main/java/app/closer/notifications/NotificationRateLimiter.kt index 30e203f9..ba936700 100644 --- a/app/src/main/java/app/closer/notifications/NotificationRateLimiter.kt +++ b/app/src/main/java/app/closer/notifications/NotificationRateLimiter.kt @@ -15,6 +15,11 @@ import java.util.concurrent.TimeUnit * - 1 reminder notification per day (proactive nudges stay gentle) * - 100 total notifications per week * + * Budget separation: **partner-action / high-value pushes (game start/finish, results-ready, + * partner answered, date match) are NOT subject to the weekly promotional ceiling** — they are the + * core loop and should always reach the partner, bounded only by the generous 20/day anti-runaway + * cap. The 100/week ceiling exists to stop runaway *promotional* sends, so it only gates REMINDERs. + * * Note: chat messages are NOT throttled here — foreground messages show the in-app bubble and * backgrounded ones are displayed by the OS from the FCM notification block, both bypassing this. * @@ -48,11 +53,13 @@ class NotificationRateLimiter(context: Context) { */ fun canSend(type: Type): Boolean { resetIfNewWindows() - if (isTotalOverLimit()) return false - return when (type) { + // Core-loop partner-action pushes: only the daily anti-runaway cap, NOT the weekly + // promotional ceiling — a legitimate results-ready push must not be suppressed by it. Type.PARTNER_TRIGGER -> prefs.getInt(KEY_PARTNER_COUNT, 0) < MAX_PARTNER_PER_DAY - Type.REMINDER -> prefs.getInt(KEY_REMINDER_COUNT, 0) < MAX_REMINDER_PER_DAY + // Promotional reminders stay bound by both the daily and the weekly ceilings. + Type.REMINDER -> + !isTotalOverLimit() && prefs.getInt(KEY_REMINDER_COUNT, 0) < MAX_REMINDER_PER_DAY } } diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt index e1cd2551..5238e734 100644 --- a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -35,7 +35,8 @@ class PartnerNotificationManager @Inject constructor( private val settingsRepository: SettingsRepository, private val quietHoursManager: QuietHoursManager, private val rateLimiter: NotificationRateLimiter, - private val activeThreadMonitor: ActiveThreadMonitor + private val activeThreadMonitor: ActiveThreadMonitor, + private val activeGameSessionMonitor: ActiveGameSessionMonitor ) { /** @@ -61,6 +62,14 @@ class PartnerNotificationManager @Inject constructor( payload.conversationId == activeThreadMonitor.activeConversationId ) return + // Don't ping about results / "partner finished" for a game the user is already viewing live + // (both partners finishing at once would otherwise each get a redundant results push). + if ((type == PartnerNotificationType.GAME_RESULTS_READY || + type == PartnerNotificationType.PARTNER_COMPLETED_PART) && + payload.gameSessionId != null && + payload.gameSessionId == activeGameSessionMonitor.activeSessionId + ) return + if (!isEnabled(type, settings)) return if (quietHoursManager.isInQuietHours(settings.quietHours)) return if (!rateLimiter.canSend(type.rateType)) return diff --git a/app/src/main/java/app/closer/ui/auth/ForgotPasswordScreen.kt b/app/src/main/java/app/closer/ui/auth/ForgotPasswordScreen.kt index d2975038..96ab1cc2 100644 --- a/app/src/main/java/app/closer/ui/auth/ForgotPasswordScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/ForgotPasswordScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import app.closer.ui.components.BrandMessageRotator import app.closer.ui.components.CloserHeartLoader import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -136,6 +137,8 @@ fun ForgotPasswordScreen( color = AuthMuted, textAlign = TextAlign.Center ) + Spacer(Modifier.height(20.dp)) + BrandMessageRotator(color = AuthMuted) Spacer(Modifier.height(32.dp)) OutlinedTextField( value = state.email, diff --git a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt index cbab4170..6dd01750 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import app.closer.ui.components.BrandMessageRotator import app.closer.ui.components.CloserHeartLoader import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -137,6 +138,9 @@ fun SignUpScreen( textAlign = TextAlign.Center ) + Spacer(Modifier.height(20.dp)) + BrandMessageRotator(color = AuthMuted) + Spacer(Modifier.height(32.dp)) OutlinedTextField( diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index 14bee98e..293326c5 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -123,7 +123,8 @@ class DesireSyncViewModel @Inject constructor( private val repository: QuestionRepository, private val gameSessionManager: GameSessionManager, private val dataSource: FirestoreDesireSyncDataSource, - private val premiumChecker: CouplePremiumChecker + private val premiumChecker: CouplePremiumChecker, + private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor ) : ViewModel() { private val _uiState = MutableStateFlow(DesireSyncUiState()) @@ -133,6 +134,11 @@ class DesireSyncViewModel @Inject constructor( private var partnerId: String? = null private var coupleId: String? = null private var sessionId: String? = null + + override fun onCleared() { + super.onCleared() + sessionId?.let { activeGameSessionMonitor.leave(it) } + } private var observeJob: Job? = null /** True once this user's picks are written, so quitting won't cancel the shared session. */ @@ -204,6 +210,7 @@ class DesireSyncViewModel @Inject constructor( when { startResult.isSuccess -> { sessionId = startResult.getOrThrow() + sessionId?.let { activeGameSessionMonitor.enter(it) } _uiState.update { it.copy(phase = DesireSyncPhase.INTRO, questions = questions) } observeReveal() } @@ -219,6 +226,7 @@ class DesireSyncViewModel @Inject constructor( /** Second partner: join the in-flight session with the identical question set, same order. */ private suspend fun joinSession(existingSessionId: String, questionIds: List) { sessionId = existingSessionId + activeGameSessionMonitor.enter(existingSessionId) val byId = loadNeutralQuestions().associateBy { it.id } val questions = questionIds.mapNotNull { byId[it] } if (questions.isEmpty()) return fail("Couldn't load this round's questions.") diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 6133db74..8b33bd1a 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -619,9 +619,9 @@ class HomeViewModel @Inject constructor( ) Priority.GAME_WAITING -> HomeAction( - eyebrow = "Game waiting", - title = "Your partner is waiting to play.", - body = "A game is ready for the two of you. Jump back in and keep the ritual going.", + eyebrow = "Your turn", + title = "Your turn to play.", + body = "Your partner already played their part — take your turn to reveal how you two line up.", cta = "Play now", target = HomeActionTarget.Game, tone = HomeActionTone.Ritual, diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index e7bff9e1..5ad83ab7 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -157,7 +157,8 @@ class HowWellViewModel @Inject constructor( @ApplicationContext private val context: Context, private val repository: QuestionRepository, private val gameSessionManager: GameSessionManager, - private val dataSource: FirestoreHowWellDataSource + private val dataSource: FirestoreHowWellDataSource, + private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor ) : ViewModel() { private val _uiState = MutableStateFlow(HowWellUiState()) @@ -167,6 +168,11 @@ class HowWellViewModel @Inject constructor( private var partnerId: String? = null private var coupleId: String? = null private var sessionId: String? = null + + override fun onCleared() { + super.onCleared() + sessionId?.let { activeGameSessionMonitor.leave(it) } + } private var startedByUserId: String? = null private val myAnswers = mutableListOf() private var observeJob: Job? = null @@ -237,6 +243,7 @@ class HowWellViewModel @Inject constructor( when { startResult.isSuccess -> { sessionId = startResult.getOrThrow() + sessionId?.let { activeGameSessionMonitor.enter(it) } startedByUserId = uid _uiState.update { it.copy(phase = HowWellPhase.INTRO, questions = questions, amSubject = true) @@ -260,6 +267,7 @@ class HowWellViewModel @Inject constructor( questionIds: List ) { sessionId = existingSessionId + activeGameSessionMonitor.enter(existingSessionId) startedByUserId = startedBy val questions = questionIds.mapNotNull { repository.getQuestionById(it) } if (questions.isEmpty()) return fail("Couldn't load this round's questions.") diff --git a/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt b/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt index 1b5c54c7..a42c9c20 100644 --- a/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt +++ b/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt @@ -269,14 +269,14 @@ private fun SexStep( ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "What's your sex?", + text = "How do you identify?", style = MaterialTheme.typography.headlineMedium, color = AuthInk, textAlign = TextAlign.Center ) Spacer(Modifier.height(12.dp)) Text( - text = "Some questions — especially in Desire Sync — are tailored based on your sex. This just helps us show you the right ones.", + text = "This helps us personalize a few things. Pick what fits best — you can change it anytime in Settings.", style = MaterialTheme.typography.bodyMedium, color = AuthMuted, textAlign = TextAlign.Center @@ -295,6 +295,18 @@ private fun SexStep( selected = state.sex == "male", onClick = { onSexSelected("male") } ) + Spacer(Modifier.height(12.dp)) + SexOption( + label = "Non-binary", + selected = state.sex == "nonbinary", + onClick = { onSexSelected("nonbinary") } + ) + Spacer(Modifier.height(12.dp)) + SexOption( + label = "Prefer not to say", + selected = state.sex == "unspecified", + onClick = { onSexSelected("unspecified") } + ) state.sexError?.let { Spacer(Modifier.height(12.dp)) diff --git a/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt b/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt index 936f9ee1..7bd219e7 100644 --- a/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt +++ b/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt @@ -90,7 +90,7 @@ class CreateProfileViewModel @Inject constructor( } ProfileStep.SEX -> { if (state.sex.isBlank()) { - _uiState.update { it.copy(sexError = "Please select an option so we can tailor your experience.") } + _uiState.update { it.copy(sexError = "Please choose an option to continue.") } } else { _uiState.update { it.copy(currentStep = ProfileStep.PHOTO, sexError = null) } } @@ -108,7 +108,7 @@ class CreateProfileViewModel @Inject constructor( _uiState.update { it.copy( nameError = if (name.isBlank()) "Please enter your name." else if (name.length < 2) "Name must be at least 2 characters." else null, - sexError = if (state.sex.isBlank()) "Please select your sex." else null + sexError = if (state.sex.isBlank()) "Please choose an option." else null ) } return diff --git a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt index 177a9bea..3ef49d67 100644 --- a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt +++ b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt @@ -118,11 +118,14 @@ fun PaywallScreen( modifier = Modifier.fillMaxWidth() ) uiState.error != null -> ErrorState( - title = "Couldn't load plans", + title = if (uiState.isOffline) "You're offline" else "Couldn't load plans", // Always show friendly copy — the raw billing/RevenueCat SDK message (e.g. // "There was a credentials issue. Check the underlying error…") is developer // detail and must never surface to users. A-OBS. - message = "We couldn't load subscription options right now. Check your connection and tap to try again.", + message = if (uiState.isOffline) + "You're offline. Reconnect to see subscription options, then tap to try again." + else + "We couldn't load subscription options right now. Check your connection and tap to try again.", retryLabel = "Try again", onRetry = { viewModel.retry() }, modifier = Modifier.fillMaxWidth() @@ -141,6 +144,7 @@ fun PaywallScreen( } ActionButtons( + showContinue = uiState.packages.isNotEmpty(), canPurchase = uiState.selectedPackage != null && uiState.purchaseState !is BillingState.Loading, onPurchase = { val activity = context.findActivity() @@ -366,6 +370,7 @@ private fun PlanRow( @Composable private fun ActionButtons( + showContinue: Boolean, canPurchase: Boolean, onPurchase: () -> Unit, onRestore: () -> Unit, @@ -376,19 +381,22 @@ private fun ActionButtons( verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Button( - onClick = onPurchase, - enabled = canPurchase, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = CloserPalette.PurpleDeep, - contentColor = MaterialTheme.colorScheme.onPrimary, - disabledContainerColor = CloserPalette.PurpleRich.copy(alpha = 0.40f), - disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.54f) - ) - ) { - Text("Continue", fontWeight = FontWeight.SemiBold) + // Only show Continue once plans have loaded — never a dead, disabled button. (Future.md QA) + if (showContinue) { + Button( + onClick = onPurchase, + enabled = canPurchase, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = CloserPalette.PurpleDeep, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = CloserPalette.PurpleRich.copy(alpha = 0.40f), + disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.54f) + ) + ) { + Text("Continue", fontWeight = FontWeight.SemiBold) + } } Card( diff --git a/app/src/main/java/app/closer/ui/paywall/PaywallViewModel.kt b/app/src/main/java/app/closer/ui/paywall/PaywallViewModel.kt index 2ed5b2b1..8fe88186 100644 --- a/app/src/main/java/app/closer/ui/paywall/PaywallViewModel.kt +++ b/app/src/main/java/app/closer/ui/paywall/PaywallViewModel.kt @@ -1,6 +1,9 @@ package app.closer.ui.paywall import android.app.Activity +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.domain.repository.BillingRepository @@ -9,7 +12,9 @@ import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.Package import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -22,7 +27,9 @@ data class PaywallUiState( val selectedPackage: Package? = null, val customerInfo: CustomerInfo? = null, val purchaseState: BillingState<*>? = null, - val error: String? = null + val error: String? = null, + /** True when the last load failure happened with no network — drives an offline-specific message. */ + val isOffline: Boolean = false ) { val packages: List get() = offerings?.current?.availablePackages ?: emptyList() @@ -30,7 +37,8 @@ data class PaywallUiState( @HiltViewModel class PaywallViewModel @Inject constructor( - private val billingRepository: BillingRepository + private val billingRepository: BillingRepository, + @ApplicationContext private val context: Context ) : ViewModel() { private val _uiState = MutableStateFlow(PaywallUiState()) @@ -43,23 +51,42 @@ class PaywallViewModel @Inject constructor( private fun loadOfferings() { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - when (val result = billingRepository.getOfferings()) { - is BillingState.Loading -> _uiState.update { it.copy(isLoading = true) } - is BillingState.Success -> _uiState.update { - it.copy( - isLoading = false, - offerings = result.data, - selectedPackage = result.data.current?.availablePackages?.firstOrNull() - ) + _uiState.update { it.copy(isLoading = true, error = null, isOffline = false) } + // Transient billing/network hiccups are common on cold start — retry a few times with + // exponential backoff before surfacing an error to the user. + repeat(MAX_LOAD_ATTEMPTS) { attempt -> + when (val result = billingRepository.getOfferings()) { + is BillingState.Loading -> Unit + is BillingState.Success -> { + _uiState.update { + it.copy( + isLoading = false, + error = null, + isOffline = false, + offerings = result.data, + selectedPackage = result.data.current?.availablePackages?.firstOrNull() + ) + } + return@launch + } + is BillingState.Error -> Unit } - is BillingState.Error -> _uiState.update { - it.copy(isLoading = false, error = result.message) + if (attempt < MAX_LOAD_ATTEMPTS - 1) { + delay(RETRY_BASE_DELAY_MS * (1L shl attempt)) } } + // All attempts failed — show a safe, offline-aware message (never the raw SDK text). A-OBS. + _uiState.update { it.copy(isLoading = false, isOffline = isOffline(), error = LOAD_FAILED) } } } + /** Best-effort connectivity check to choose between the offline and generic error copy. */ + private fun isOffline(): Boolean = runCatching { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + val caps = cm?.getNetworkCapabilities(cm.activeNetwork) + caps == null || !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + }.getOrDefault(false) + private fun observeCustomerInfo() { viewModelScope.launch { billingRepository.getCustomerInfo().collect { state -> @@ -111,4 +138,11 @@ class PaywallViewModel @Inject constructor( } fun retry() = loadOfferings() + + companion object { + private const val MAX_LOAD_ATTEMPTS = 3 + private const val RETRY_BASE_DELAY_MS = 600L + // Marker only — the screen renders a curated, offline-aware message; the raw value is never shown. + private const val LOAD_FAILED = "load_failed" + } } diff --git a/app/src/main/java/app/closer/ui/settings/EditProfileScreen.kt b/app/src/main/java/app/closer/ui/settings/EditProfileScreen.kt index dc5f6363..24836b67 100644 --- a/app/src/main/java/app/closer/ui/settings/EditProfileScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/EditProfileScreen.kt @@ -316,29 +316,48 @@ fun EditProfileContent( ) Spacer(Modifier.height(8.dp)) Text( - text = "We use this to show you the most relevant questions in Desire Sync and other features.", + text = "This helps us personalize a few things. You can change it anytime.", style = MaterialTheme.typography.bodyMedium, color = SettingsMuted, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - SexEditOption( - label = "Female", - selected = state.sex == "female", - onClick = { viewModel.selectSex("female") }, - modifier = Modifier.weight(1f) - ) - SexEditOption( - label = "Male", - selected = state.sex == "male", - onClick = { viewModel.selectSex("male") }, - modifier = Modifier.weight(1f) - ) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SexEditOption( + label = "Female", + selected = state.sex == "female", + onClick = { viewModel.selectSex("female") }, + modifier = Modifier.weight(1f) + ) + SexEditOption( + label = "Male", + selected = state.sex == "male", + onClick = { viewModel.selectSex("male") }, + modifier = Modifier.weight(1f) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SexEditOption( + label = "Non-binary", + selected = state.sex == "nonbinary", + onClick = { viewModel.selectSex("nonbinary") }, + modifier = Modifier.weight(1f) + ) + SexEditOption( + label = "Prefer not to say", + selected = state.sex == "unspecified", + onClick = { viewModel.selectSex("unspecified") }, + modifier = Modifier.weight(1f) + ) + } } state.sexError?.let { Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt b/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt index d3c0d964..7ace9e88 100644 --- a/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt +++ b/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt @@ -92,7 +92,7 @@ class EditProfileViewModel @Inject constructor( return } state.sex.isBlank() -> { - _uiState.update { it.copy(sexError = "Please select your sex.") } + _uiState.update { it.copy(sexError = "Please choose an option.") } return } } diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index ce12d32d..5958fd15 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -152,7 +152,8 @@ class ThisOrThatViewModel @Inject constructor( @ApplicationContext private val context: Context, private val repository: QuestionRepository, private val gameSessionManager: GameSessionManager, - private val dataSource: FirestoreThisOrThatDataSource + private val dataSource: FirestoreThisOrThatDataSource, + private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor ) : ViewModel() { private val _uiState = MutableStateFlow(ThisOrThatUiState()) @@ -162,6 +163,11 @@ class ThisOrThatViewModel @Inject constructor( private var partnerId: String? = null private var coupleId: String? = null private var sessionId: String? = null + + override fun onCleared() { + super.onCleared() + sessionId?.let { activeGameSessionMonitor.leave(it) } + } private var observeJob: Job? = null /** True once this user's picks are written, so quitting won't cancel the shared session. */ @@ -239,6 +245,7 @@ class ThisOrThatViewModel @Inject constructor( when { startResult.isSuccess -> { sessionId = startResult.getOrThrow() + sessionId?.let { activeGameSessionMonitor.enter(it) } _uiState.update { it.copy(phase = TotPhase.PLAYING, questions = picked) } observeReveal() } @@ -254,6 +261,7 @@ class ThisOrThatViewModel @Inject constructor( /** Second partner: join the in-flight session with the exact same prompts, in the same order. */ private suspend fun joinSession(existingSessionId: String, questionIds: List) { sessionId = existingSessionId + activeGameSessionMonitor.enter(existingSessionId) val byId = runCatching { repository.getQuestionsByType("this_or_that") } .getOrElse { emptyList() } .associateBy { it.id } diff --git a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt index 5ab76c49..0b24c726 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt @@ -92,7 +92,8 @@ data class WheelCompleteUiState( class WheelCompleteViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val gameSessionManager: GameSessionManager, - private val answerDataSource: FirestoreWheelAnswerDataSource + private val answerDataSource: FirestoreWheelAnswerDataSource, + private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor ) : ViewModel() { private val sessionId: String = savedStateHandle["sessionId"] ?: "" @@ -105,9 +106,15 @@ class WheelCompleteViewModel @Inject constructor( private var partnerId: String? = null init { + activeGameSessionMonitor.enter(sessionId) observe() } + override fun onCleared() { + super.onCleared() + activeGameSessionMonitor.leave(sessionId) + } + private fun observe() { viewModelScope.launch { val uid = gameSessionManager.currentUserId diff --git a/app/src/test/java/app/closer/notifications/NotificationRateLimiterTest.kt b/app/src/test/java/app/closer/notifications/NotificationRateLimiterTest.kt index 37302f95..f61e61d1 100644 --- a/app/src/test/java/app/closer/notifications/NotificationRateLimiterTest.kt +++ b/app/src/test/java/app/closer/notifications/NotificationRateLimiterTest.kt @@ -11,28 +11,31 @@ import org.junit.Test class NotificationRateLimiterTest { - private fun createLimiter(): NotificationRateLimiter { + private fun createLimiter(): Pair { + val prefs = InMemorySharedPreferences() val context = mockk() - every { context.getSharedPreferences("notification_rate_limits", Context.MODE_PRIVATE) } returns InMemorySharedPreferences() - return NotificationRateLimiter(context) + every { context.getSharedPreferences("notification_rate_limits", Context.MODE_PRIVATE) } returns prefs + return NotificationRateLimiter(context) to prefs } @Test - fun `partner trigger limit allows up to two per day`() { - val limiter = createLimiter() + fun `partner trigger allows up to the daily cap`() { + val (limiter, _) = createLimiter() - assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) - limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) - assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) - limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) + repeat(NotificationRateLimiter.MAX_PARTNER_PER_DAY) { + assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) + limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) + } assertFalse(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) - - assertEquals(2, limiter.count(NotificationRateLimiter.Type.PARTNER_TRIGGER)) + assertEquals( + NotificationRateLimiter.MAX_PARTNER_PER_DAY, + limiter.count(NotificationRateLimiter.Type.PARTNER_TRIGGER) + ) } @Test fun `reminder limit allows one per day`() { - val limiter = createLimiter() + val (limiter, _) = createLimiter() assertTrue(limiter.canSend(NotificationRateLimiter.Type.REMINDER)) limiter.record(NotificationRateLimiter.Type.REMINDER) @@ -42,23 +45,26 @@ class NotificationRateLimiterTest { } @Test - fun `weekly total limit blocks when four notifications have been recorded`() { - val limiter = createLimiter() + fun `weekly total ceiling blocks reminders but exempts partner-action pushes`() { + val (limiter, prefs) = createLimiter() - // Record four partner-trigger notifications directly so the weekly total - // reaches its cap independent of the daily partner-trigger limit. - repeat(4) { - limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) - } + // Establish the current day/week window, then push the weekly total to its ceiling + // while keeping the partner daily count low. + limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) + prefs.edit() + .putInt("total_count", NotificationRateLimiter.MAX_TOTAL_PER_WEEK) + .putInt("partner_count", 0) + .apply() - assertEquals(4, limiter.totalCount()) + // Promotional reminders are gated by the weekly ceiling… assertFalse(limiter.canSend(NotificationRateLimiter.Type.REMINDER)) - assertFalse(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) + // …but core-loop partner-action pushes are NOT. + assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) } @Test fun `mixed types count toward weekly total`() { - val limiter = createLimiter() + val (limiter, _) = createLimiter() assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) @@ -70,7 +76,7 @@ class NotificationRateLimiterTest { @Test fun `reminder does not count toward partner daily limit`() { - val limiter = createLimiter() + val (limiter, _) = createLimiter() repeat(2) { limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) diff --git a/app/src/test/java/app/closer/notifications/PartnerNotificationManagerTest.kt b/app/src/test/java/app/closer/notifications/PartnerNotificationManagerTest.kt index be308c76..628b7cf3 100644 --- a/app/src/test/java/app/closer/notifications/PartnerNotificationManagerTest.kt +++ b/app/src/test/java/app/closer/notifications/PartnerNotificationManagerTest.kt @@ -27,6 +27,8 @@ class PartnerNotificationManagerTest { private val settingsRepository = mockk() private val quietHoursManager = mockk() private val rateLimiter = mockk(relaxed = true) + private val activeThreadMonitor = mockk(relaxed = true) + private val activeGameSessionMonitor = mockk(relaxed = true) private val notificationManagerCompat = mockk(relaxed = true) private lateinit var manager: PartnerNotificationManager @@ -39,7 +41,7 @@ class PartnerNotificationManagerTest { partnerAnsweredEnabled = true ) ) - every { quietHoursManager.isInQuietHours(any(), any()) } returns false + every { quietHoursManager.isInQuietHours(any()) } returns false every { rateLimiter.canSend(any()) } returns true mockkStatic(NotificationManagerCompat::from) @@ -55,7 +57,10 @@ class PartnerNotificationManagerTest { every { anyConstructed().setCategory(any()) } returns mockk(relaxed = true) every { anyConstructed().build() } returns mockk(relaxed = true) - manager = PartnerNotificationManager(context, settingsRepository, quietHoursManager, rateLimiter) + manager = PartnerNotificationManager( + context, settingsRepository, quietHoursManager, rateLimiter, + activeThreadMonitor, activeGameSessionMonitor + ) } @After