feat(qa): clear Future.md backlog — inclusive gender, turn-aware copy, push budgets, paywall polish, auth rotator

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-06-25 16:00:58 -05:00
parent 95cad84cb5
commit f47c8e2b64
20 changed files with 277 additions and 115 deletions

View File

@ -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). 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**, - **Consistent brand glyphs across game cards + waiting/notification surfaces.** _(Blocked: needs the
and it drives Desire Sync question tailoring. For a couples app, consider adding a non-binary / "prefer to generated G-set art — image generation is the user's step per `ClaudeBrandingReview.md`.)_ Game cards
self-describe" / "prefer not to say" option (with a sensible tailoring fallback). *Prompted by:* Pass G sign-up flow. (Play hub), the WaitingForPartner screen, and notifications mix Material icons with brand art. A small
- **Home "waiting to play" copy doesn't say whose turn it is.** Both partners' Home shows "**Your** partner is waiting custom glyph set (the C-heart-keyhole mark, paired/sealed card, daily card, capsule, date-card,
to play" for the *same* session, so each thinks the other is mid-game. Consider turn-aware copy ("Your turn — quiet-hours moon) used consistently would strengthen identity. Generate the G-set, drop the assets in,
finish your part" vs "Waiting on {partner}"). *Prompted by:* B-002 (resume is fixed; this is the copy nuance). then wire them in. *Prompted by:* Pass H branding review.
- **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.
> Artwork to generate (ChatGPT prompts, house-style-matched) lives in `ClaudeBrandingReview.md`, not here. > Artwork to generate (ChatGPT prompts, house-style-matched) lives in `ClaudeBrandingReview.md`, not here.
<!--
Completed (2026-06-25) and removed from the backlog:
- Inclusive sex/gender options in onboarding (Female/Male/Non-binary/Prefer not to say) + honest copy
(Desire Sync is already gender-neutral, so no tailoring fallback was needed).
- Turn-aware Home "waiting to play" copy ("Your turn to play.").
- Partner-action/results pushes exempt from the weekly promotional rate-limit ceiling.
- Suppress the redundant results / "partner finished" push when the recipient is already on that
game's screen (new ActiveGameSessionMonitor mirroring ActiveThreadMonitor).
- Friendlier paywall error state: retry-with-backoff, offline-aware message, Continue hidden until plans load.
- Wire iOS illustrations into Android empty states — already wired (history, invite, daily-question,
together-empty, partner-activation all show their illustration).
- Rotating privacy messages on sign-up + forgot-password (login already had it).
-->

View File

@ -38,12 +38,14 @@ object NotificationModule {
settingsRepository: SettingsRepository, settingsRepository: SettingsRepository,
quietHoursManager: QuietHoursManager, quietHoursManager: QuietHoursManager,
rateLimiter: NotificationRateLimiter, rateLimiter: NotificationRateLimiter,
activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor,
activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
): PartnerNotificationManager = PartnerNotificationManager( ): PartnerNotificationManager = PartnerNotificationManager(
context, context,
settingsRepository, settingsRepository,
quietHoursManager, quietHoursManager,
rateLimiter, rateLimiter,
activeThreadMonitor activeThreadMonitor,
activeGameSessionMonitor
) )
} }

View File

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

View File

@ -15,6 +15,11 @@ import java.util.concurrent.TimeUnit
* - 1 reminder notification per day (proactive nudges stay gentle) * - 1 reminder notification per day (proactive nudges stay gentle)
* - 100 total notifications per week * - 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 * 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. * 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 { fun canSend(type: Type): Boolean {
resetIfNewWindows() resetIfNewWindows()
if (isTotalOverLimit()) return false
return when (type) { 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.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
} }
} }

View File

@ -35,7 +35,8 @@ class PartnerNotificationManager @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val quietHoursManager: QuietHoursManager, private val quietHoursManager: QuietHoursManager,
private val rateLimiter: NotificationRateLimiter, 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 payload.conversationId == activeThreadMonitor.activeConversationId
) return ) 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 (!isEnabled(type, settings)) return
if (quietHoursManager.isInQuietHours(settings.quietHours)) return if (quietHoursManager.isInQuietHours(settings.quietHours)) return
if (!rateLimiter.canSend(type.rateType)) return if (!rateLimiter.canSend(type.rateType)) return

View File

@ -20,6 +20,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import app.closer.ui.components.BrandMessageRotator
import app.closer.ui.components.CloserHeartLoader import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -136,6 +137,8 @@ fun ForgotPasswordScreen(
color = AuthMuted, color = AuthMuted,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(20.dp))
BrandMessageRotator(color = AuthMuted)
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
OutlinedTextField( OutlinedTextField(
value = state.email, value = state.email,

View File

@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import app.closer.ui.components.BrandMessageRotator
import app.closer.ui.components.CloserHeartLoader import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -137,6 +138,9 @@ fun SignUpScreen(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(20.dp))
BrandMessageRotator(color = AuthMuted)
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
OutlinedTextField( OutlinedTextField(

View File

@ -123,7 +123,8 @@ class DesireSyncViewModel @Inject constructor(
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager, private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource, private val dataSource: FirestoreDesireSyncDataSource,
private val premiumChecker: CouplePremiumChecker private val premiumChecker: CouplePremiumChecker,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState()) private val _uiState = MutableStateFlow(DesireSyncUiState())
@ -133,6 +134,11 @@ class DesireSyncViewModel @Inject constructor(
private var partnerId: String? = null private var partnerId: String? = null
private var coupleId: String? = null private var coupleId: String? = null
private var sessionId: String? = null private var sessionId: String? = null
override fun onCleared() {
super.onCleared()
sessionId?.let { activeGameSessionMonitor.leave(it) }
}
private var observeJob: Job? = null private var observeJob: Job? = null
/** True once this user's picks are written, so quitting won't cancel the shared session. */ /** 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 { when {
startResult.isSuccess -> { startResult.isSuccess -> {
sessionId = startResult.getOrThrow() sessionId = startResult.getOrThrow()
sessionId?.let { activeGameSessionMonitor.enter(it) }
_uiState.update { it.copy(phase = DesireSyncPhase.INTRO, questions = questions) } _uiState.update { it.copy(phase = DesireSyncPhase.INTRO, questions = questions) }
observeReveal() observeReveal()
} }
@ -219,6 +226,7 @@ class DesireSyncViewModel @Inject constructor(
/** Second partner: join the in-flight session with the identical question set, same order. */ /** Second partner: join the in-flight session with the identical question set, same order. */
private suspend fun joinSession(existingSessionId: String, questionIds: List<String>) { private suspend fun joinSession(existingSessionId: String, questionIds: List<String>) {
sessionId = existingSessionId sessionId = existingSessionId
activeGameSessionMonitor.enter(existingSessionId)
val byId = loadNeutralQuestions().associateBy { it.id } val byId = loadNeutralQuestions().associateBy { it.id }
val questions = questionIds.mapNotNull { byId[it] } val questions = questionIds.mapNotNull { byId[it] }
if (questions.isEmpty()) return fail("Couldn't load this round's questions.") if (questions.isEmpty()) return fail("Couldn't load this round's questions.")

View File

@ -619,9 +619,9 @@ class HomeViewModel @Inject constructor(
) )
Priority.GAME_WAITING -> HomeAction( Priority.GAME_WAITING -> HomeAction(
eyebrow = "Game waiting", eyebrow = "Your turn",
title = "Your partner is waiting to play.", title = "Your turn to play.",
body = "A game is ready for the two of you. Jump back in and keep the ritual going.", body = "Your partner already played their part — take your turn to reveal how you two line up.",
cta = "Play now", cta = "Play now",
target = HomeActionTarget.Game, target = HomeActionTarget.Game,
tone = HomeActionTone.Ritual, tone = HomeActionTone.Ritual,

View File

@ -157,7 +157,8 @@ class HowWellViewModel @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager, private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreHowWellDataSource private val dataSource: FirestoreHowWellDataSource,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(HowWellUiState()) private val _uiState = MutableStateFlow(HowWellUiState())
@ -167,6 +168,11 @@ class HowWellViewModel @Inject constructor(
private var partnerId: String? = null private var partnerId: String? = null
private var coupleId: String? = null private var coupleId: String? = null
private var sessionId: String? = null private var sessionId: String? = null
override fun onCleared() {
super.onCleared()
sessionId?.let { activeGameSessionMonitor.leave(it) }
}
private var startedByUserId: String? = null private var startedByUserId: String? = null
private val myAnswers = mutableListOf<HowWellRawAnswer>() private val myAnswers = mutableListOf<HowWellRawAnswer>()
private var observeJob: Job? = null private var observeJob: Job? = null
@ -237,6 +243,7 @@ class HowWellViewModel @Inject constructor(
when { when {
startResult.isSuccess -> { startResult.isSuccess -> {
sessionId = startResult.getOrThrow() sessionId = startResult.getOrThrow()
sessionId?.let { activeGameSessionMonitor.enter(it) }
startedByUserId = uid startedByUserId = uid
_uiState.update { _uiState.update {
it.copy(phase = HowWellPhase.INTRO, questions = questions, amSubject = true) it.copy(phase = HowWellPhase.INTRO, questions = questions, amSubject = true)
@ -260,6 +267,7 @@ class HowWellViewModel @Inject constructor(
questionIds: List<String> questionIds: List<String>
) { ) {
sessionId = existingSessionId sessionId = existingSessionId
activeGameSessionMonitor.enter(existingSessionId)
startedByUserId = startedBy startedByUserId = startedBy
val questions = questionIds.mapNotNull { repository.getQuestionById(it) } val questions = questionIds.mapNotNull { repository.getQuestionById(it) }
if (questions.isEmpty()) return fail("Couldn't load this round's questions.") if (questions.isEmpty()) return fail("Couldn't load this round's questions.")

View File

@ -269,14 +269,14 @@ private fun SexStep(
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = "What's your sex?", text = "How do you identify?",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = AuthInk, color = AuthInk,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = AuthMuted, color = AuthMuted,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@ -295,6 +295,18 @@ private fun SexStep(
selected = state.sex == "male", selected = state.sex == "male",
onClick = { onSexSelected("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 { state.sexError?.let {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))

View File

@ -90,7 +90,7 @@ class CreateProfileViewModel @Inject constructor(
} }
ProfileStep.SEX -> { ProfileStep.SEX -> {
if (state.sex.isBlank()) { 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 { } else {
_uiState.update { it.copy(currentStep = ProfileStep.PHOTO, sexError = null) } _uiState.update { it.copy(currentStep = ProfileStep.PHOTO, sexError = null) }
} }
@ -108,7 +108,7 @@ class CreateProfileViewModel @Inject constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
nameError = if (name.isBlank()) "Please enter your name." else if (name.length < 2) "Name must be at least 2 characters." else null, 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 return

View File

@ -118,11 +118,14 @@ fun PaywallScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
uiState.error != null -> ErrorState( 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. // Always show friendly copy — the raw billing/RevenueCat SDK message (e.g.
// "There was a credentials issue. Check the underlying error…") is developer // "There was a credentials issue. Check the underlying error…") is developer
// detail and must never surface to users. A-OBS. // 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", retryLabel = "Try again",
onRetry = { viewModel.retry() }, onRetry = { viewModel.retry() },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@ -141,6 +144,7 @@ fun PaywallScreen(
} }
ActionButtons( ActionButtons(
showContinue = uiState.packages.isNotEmpty(),
canPurchase = uiState.selectedPackage != null && uiState.purchaseState !is BillingState.Loading, canPurchase = uiState.selectedPackage != null && uiState.purchaseState !is BillingState.Loading,
onPurchase = { onPurchase = {
val activity = context.findActivity() val activity = context.findActivity()
@ -366,6 +370,7 @@ private fun PlanRow(
@Composable @Composable
private fun ActionButtons( private fun ActionButtons(
showContinue: Boolean,
canPurchase: Boolean, canPurchase: Boolean,
onPurchase: () -> Unit, onPurchase: () -> Unit,
onRestore: () -> Unit, onRestore: () -> Unit,
@ -376,19 +381,22 @@ private fun ActionButtons(
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Button( // Only show Continue once plans have loaded — never a dead, disabled button. (Future.md QA)
onClick = onPurchase, if (showContinue) {
enabled = canPurchase, Button(
modifier = Modifier.fillMaxWidth(), onClick = onPurchase,
shape = RoundedCornerShape(16.dp), enabled = canPurchase,
colors = ButtonDefaults.buttonColors( modifier = Modifier.fillMaxWidth(),
containerColor = CloserPalette.PurpleDeep, shape = RoundedCornerShape(16.dp),
contentColor = MaterialTheme.colorScheme.onPrimary, colors = ButtonDefaults.buttonColors(
disabledContainerColor = CloserPalette.PurpleRich.copy(alpha = 0.40f), containerColor = CloserPalette.PurpleDeep,
disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.54f) contentColor = MaterialTheme.colorScheme.onPrimary,
) disabledContainerColor = CloserPalette.PurpleRich.copy(alpha = 0.40f),
) { disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.54f)
Text("Continue", fontWeight = FontWeight.SemiBold) )
) {
Text("Continue", fontWeight = FontWeight.SemiBold)
}
} }
Card( Card(

View File

@ -1,6 +1,9 @@
package app.closer.ui.paywall package app.closer.ui.paywall
import android.app.Activity import android.app.Activity
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.domain.repository.BillingRepository import app.closer.domain.repository.BillingRepository
@ -9,7 +12,9 @@ import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.Package import com.revenuecat.purchases.Package
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -22,7 +27,9 @@ data class PaywallUiState(
val selectedPackage: Package? = null, val selectedPackage: Package? = null,
val customerInfo: CustomerInfo? = null, val customerInfo: CustomerInfo? = null,
val purchaseState: BillingState<*>? = 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<Package> val packages: List<Package>
get() = offerings?.current?.availablePackages ?: emptyList() get() = offerings?.current?.availablePackages ?: emptyList()
@ -30,7 +37,8 @@ data class PaywallUiState(
@HiltViewModel @HiltViewModel
class PaywallViewModel @Inject constructor( class PaywallViewModel @Inject constructor(
private val billingRepository: BillingRepository private val billingRepository: BillingRepository,
@ApplicationContext private val context: Context
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(PaywallUiState()) private val _uiState = MutableStateFlow(PaywallUiState())
@ -43,23 +51,42 @@ class PaywallViewModel @Inject constructor(
private fun loadOfferings() { private fun loadOfferings() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null, isOffline = false) }
when (val result = billingRepository.getOfferings()) { // Transient billing/network hiccups are common on cold start — retry a few times with
is BillingState.Loading -> _uiState.update { it.copy(isLoading = true) } // exponential backoff before surfacing an error to the user.
is BillingState.Success -> _uiState.update { repeat(MAX_LOAD_ATTEMPTS) { attempt ->
it.copy( when (val result = billingRepository.getOfferings()) {
isLoading = false, is BillingState.Loading -> Unit
offerings = result.data, is BillingState.Success -> {
selectedPackage = result.data.current?.availablePackages?.firstOrNull() _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 { if (attempt < MAX_LOAD_ATTEMPTS - 1) {
it.copy(isLoading = false, error = result.message) 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() { private fun observeCustomerInfo() {
viewModelScope.launch { viewModelScope.launch {
billingRepository.getCustomerInfo().collect { state -> billingRepository.getCustomerInfo().collect { state ->
@ -111,4 +138,11 @@ class PaywallViewModel @Inject constructor(
} }
fun retry() = loadOfferings() 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"
}
} }

View File

@ -316,29 +316,48 @@ fun EditProfileContent(
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted, color = SettingsMuted,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Row( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
modifier = Modifier.fillMaxWidth(), Row(
horizontalArrangement = Arrangement.spacedBy(12.dp) modifier = Modifier.fillMaxWidth(),
) { horizontalArrangement = Arrangement.spacedBy(12.dp)
SexEditOption( ) {
label = "Female", SexEditOption(
selected = state.sex == "female", label = "Female",
onClick = { viewModel.selectSex("female") }, selected = state.sex == "female",
modifier = Modifier.weight(1f) onClick = { viewModel.selectSex("female") },
) modifier = Modifier.weight(1f)
SexEditOption( )
label = "Male", SexEditOption(
selected = state.sex == "male", label = "Male",
onClick = { viewModel.selectSex("male") }, selected = state.sex == "male",
modifier = Modifier.weight(1f) 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 { state.sexError?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))

View File

@ -92,7 +92,7 @@ class EditProfileViewModel @Inject constructor(
return return
} }
state.sex.isBlank() -> { state.sex.isBlank() -> {
_uiState.update { it.copy(sexError = "Please select your sex.") } _uiState.update { it.copy(sexError = "Please choose an option.") }
return return
} }
} }

View File

@ -152,7 +152,8 @@ class ThisOrThatViewModel @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager, private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreThisOrThatDataSource private val dataSource: FirestoreThisOrThatDataSource,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ThisOrThatUiState()) private val _uiState = MutableStateFlow(ThisOrThatUiState())
@ -162,6 +163,11 @@ class ThisOrThatViewModel @Inject constructor(
private var partnerId: String? = null private var partnerId: String? = null
private var coupleId: String? = null private var coupleId: String? = null
private var sessionId: String? = null private var sessionId: String? = null
override fun onCleared() {
super.onCleared()
sessionId?.let { activeGameSessionMonitor.leave(it) }
}
private var observeJob: Job? = null private var observeJob: Job? = null
/** True once this user's picks are written, so quitting won't cancel the shared session. */ /** 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 { when {
startResult.isSuccess -> { startResult.isSuccess -> {
sessionId = startResult.getOrThrow() sessionId = startResult.getOrThrow()
sessionId?.let { activeGameSessionMonitor.enter(it) }
_uiState.update { it.copy(phase = TotPhase.PLAYING, questions = picked) } _uiState.update { it.copy(phase = TotPhase.PLAYING, questions = picked) }
observeReveal() 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. */ /** Second partner: join the in-flight session with the exact same prompts, in the same order. */
private suspend fun joinSession(existingSessionId: String, questionIds: List<String>) { private suspend fun joinSession(existingSessionId: String, questionIds: List<String>) {
sessionId = existingSessionId sessionId = existingSessionId
activeGameSessionMonitor.enter(existingSessionId)
val byId = runCatching { repository.getQuestionsByType("this_or_that") } val byId = runCatching { repository.getQuestionsByType("this_or_that") }
.getOrElse { emptyList() } .getOrElse { emptyList() }
.associateBy { it.id } .associateBy { it.id }

View File

@ -92,7 +92,8 @@ data class WheelCompleteUiState(
class WheelCompleteViewModel @Inject constructor( class WheelCompleteViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val gameSessionManager: GameSessionManager, private val gameSessionManager: GameSessionManager,
private val answerDataSource: FirestoreWheelAnswerDataSource private val answerDataSource: FirestoreWheelAnswerDataSource,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
) : ViewModel() { ) : ViewModel() {
private val sessionId: String = savedStateHandle["sessionId"] ?: "" private val sessionId: String = savedStateHandle["sessionId"] ?: ""
@ -105,9 +106,15 @@ class WheelCompleteViewModel @Inject constructor(
private var partnerId: String? = null private var partnerId: String? = null
init { init {
activeGameSessionMonitor.enter(sessionId)
observe() observe()
} }
override fun onCleared() {
super.onCleared()
activeGameSessionMonitor.leave(sessionId)
}
private fun observe() { private fun observe() {
viewModelScope.launch { viewModelScope.launch {
val uid = gameSessionManager.currentUserId val uid = gameSessionManager.currentUserId

View File

@ -11,28 +11,31 @@ import org.junit.Test
class NotificationRateLimiterTest { class NotificationRateLimiterTest {
private fun createLimiter(): NotificationRateLimiter { private fun createLimiter(): Pair<NotificationRateLimiter, SharedPreferences> {
val prefs = InMemorySharedPreferences()
val context = mockk<Context>() val context = mockk<Context>()
every { context.getSharedPreferences("notification_rate_limits", Context.MODE_PRIVATE) } returns InMemorySharedPreferences() every { context.getSharedPreferences("notification_rate_limits", Context.MODE_PRIVATE) } returns prefs
return NotificationRateLimiter(context) return NotificationRateLimiter(context) to prefs
} }
@Test @Test
fun `partner trigger limit allows up to two per day`() { fun `partner trigger allows up to the daily cap`() {
val limiter = createLimiter() val (limiter, _) = createLimiter()
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) repeat(NotificationRateLimiter.MAX_PARTNER_PER_DAY) {
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) }
assertFalse(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) assertFalse(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
assertEquals(
assertEquals(2, limiter.count(NotificationRateLimiter.Type.PARTNER_TRIGGER)) NotificationRateLimiter.MAX_PARTNER_PER_DAY,
limiter.count(NotificationRateLimiter.Type.PARTNER_TRIGGER)
)
} }
@Test @Test
fun `reminder limit allows one per day`() { fun `reminder limit allows one per day`() {
val limiter = createLimiter() val (limiter, _) = createLimiter()
assertTrue(limiter.canSend(NotificationRateLimiter.Type.REMINDER)) assertTrue(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
limiter.record(NotificationRateLimiter.Type.REMINDER) limiter.record(NotificationRateLimiter.Type.REMINDER)
@ -42,23 +45,26 @@ class NotificationRateLimiterTest {
} }
@Test @Test
fun `weekly total limit blocks when four notifications have been recorded`() { fun `weekly total ceiling blocks reminders but exempts partner-action pushes`() {
val limiter = createLimiter() val (limiter, prefs) = createLimiter()
// Record four partner-trigger notifications directly so the weekly total // Establish the current day/week window, then push the weekly total to its ceiling
// reaches its cap independent of the daily partner-trigger limit. // while keeping the partner daily count low.
repeat(4) { limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
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.REMINDER))
assertFalse(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) // …but core-loop partner-action pushes are NOT.
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
} }
@Test @Test
fun `mixed types count toward weekly total`() { fun `mixed types count toward weekly total`() {
val limiter = createLimiter() val (limiter, _) = createLimiter()
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER)) assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
@ -70,7 +76,7 @@ class NotificationRateLimiterTest {
@Test @Test
fun `reminder does not count toward partner daily limit`() { fun `reminder does not count toward partner daily limit`() {
val limiter = createLimiter() val (limiter, _) = createLimiter()
repeat(2) { repeat(2) {
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)

View File

@ -27,6 +27,8 @@ class PartnerNotificationManagerTest {
private val settingsRepository = mockk<SettingsRepository>() private val settingsRepository = mockk<SettingsRepository>()
private val quietHoursManager = mockk<QuietHoursManager>() private val quietHoursManager = mockk<QuietHoursManager>()
private val rateLimiter = mockk<NotificationRateLimiter>(relaxed = true) private val rateLimiter = mockk<NotificationRateLimiter>(relaxed = true)
private val activeThreadMonitor = mockk<ActiveThreadMonitor>(relaxed = true)
private val activeGameSessionMonitor = mockk<ActiveGameSessionMonitor>(relaxed = true)
private val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) private val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
private lateinit var manager: PartnerNotificationManager private lateinit var manager: PartnerNotificationManager
@ -39,7 +41,7 @@ class PartnerNotificationManagerTest {
partnerAnsweredEnabled = true partnerAnsweredEnabled = true
) )
) )
every { quietHoursManager.isInQuietHours(any(), any()) } returns false every { quietHoursManager.isInQuietHours(any()) } returns false
every { rateLimiter.canSend(any()) } returns true every { rateLimiter.canSend(any()) } returns true
mockkStatic(NotificationManagerCompat::from) mockkStatic(NotificationManagerCompat::from)
@ -55,7 +57,10 @@ class PartnerNotificationManagerTest {
every { anyConstructed<NotificationCompat.Builder>().setCategory(any()) } returns mockk(relaxed = true) every { anyConstructed<NotificationCompat.Builder>().setCategory(any()) } returns mockk(relaxed = true)
every { anyConstructed<NotificationCompat.Builder>().build() } returns mockk(relaxed = true) every { anyConstructed<NotificationCompat.Builder>().build() } returns mockk(relaxed = true)
manager = PartnerNotificationManager(context, settingsRepository, quietHoursManager, rateLimiter) manager = PartnerNotificationManager(
context, settingsRepository, quietHoursManager, rateLimiter,
activeThreadMonitor, activeGameSessionMonitor
)
} }
@After @After