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:
parent
95cad84cb5
commit
f47c8e2b64
47
Future.md
47
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.
|
||||
|
||||
<!--
|
||||
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).
|
||||
-->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<String>) {
|
||||
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.")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<HowWellRawAnswer>()
|
||||
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<String>
|
||||
) {
|
||||
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.")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,6 +381,8 @@ private fun ActionButtons(
|
|||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Only show Continue once plans have loaded — never a dead, disabled button. (Future.md QA)
|
||||
if (showContinue) {
|
||||
Button(
|
||||
onClick = onPurchase,
|
||||
enabled = canPurchase,
|
||||
|
|
@ -390,6 +397,7 @@ private fun ActionButtons(
|
|||
) {
|
||||
Text("Continue", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
|
|||
|
|
@ -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<Package>
|
||||
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) }
|
||||
_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 -> _uiState.update { it.copy(isLoading = true) }
|
||||
is BillingState.Success -> _uiState.update {
|
||||
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()
|
||||
)
|
||||
}
|
||||
is BillingState.Error -> _uiState.update {
|
||||
it.copy(isLoading = false, error = result.message)
|
||||
return@launch
|
||||
}
|
||||
is BillingState.Error -> Unit
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,13 +316,14 @@ 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))
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
|
|
@ -340,6 +341,24 @@ fun EditProfileContent(
|
|||
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))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>) {
|
||||
sessionId = existingSessionId
|
||||
activeGameSessionMonitor.enter(existingSessionId)
|
||||
val byId = runCatching { repository.getQuestionsByType("this_or_that") }
|
||||
.getOrElse { emptyList() }
|
||||
.associateBy { it.id }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,28 +11,31 @@ import org.junit.Test
|
|||
|
||||
class NotificationRateLimiterTest {
|
||||
|
||||
private fun createLimiter(): NotificationRateLimiter {
|
||||
private fun createLimiter(): Pair<NotificationRateLimiter, SharedPreferences> {
|
||||
val prefs = InMemorySharedPreferences()
|
||||
val context = mockk<Context>()
|
||||
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()
|
||||
|
||||
repeat(NotificationRateLimiter.MAX_PARTNER_PER_DAY) {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ class PartnerNotificationManagerTest {
|
|||
private val settingsRepository = mockk<SettingsRepository>()
|
||||
private val quietHoursManager = mockk<QuietHoursManager>()
|
||||
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 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<NotificationCompat.Builder>().setCategory(any()) } 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue