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).
|
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).
|
||||||
|
-->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
* - 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue