ConnectionChallenges series-list gains the illustration_connection_challenges_header
banner (16:9, BrandIllustration) under the title. Notification settings Quiet-hours
section gains the illustration_quiet_hours scene centered above the toggle. Verified live:
Quiet hours on dark (night-window scene reads beautifully); A3 banner + A1 (transparent,
tile=false) + A2 confirmed in the debug gallery — all crisp + on-brand. 0 FATAL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DateMatches empty -> illustration_date_match_empty; the "It is a match!" modal replaces
the heart-icon circle with illustration_date_match_success (celebration). Memory Lane
empty replaces the 📦 emoji with illustration_memory_lane_capsule. ArtPreviewScreen
(debug) now shows all 12 new illustrations via BrandIllustration so they're verifiable on
both themes without needing empty/match data. Verified live (gallery, dark): A10/A11/A12
tiles render crisp + on-brand; 0 FATAL. (Empty/match states need data not present on the
baseline couple; render path proven via the shared tile.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AnswerHistory primary empty swapped from the generic illustration_couple_history to the
purpose-made illustration_answer_history_empty (A2). WheelHistory (the "Past Games"
history screen) empty gains illustration_past_games_empty (A10). Both via the shared
EmptyState (rounded-tile, both-theme verified in Run 2). Empty states need empty data so
not reachable live on the baseline couple; render path proven via the shared component.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EmptyState already supports illustrationResId (rounded-tile clip), so Bucket List just
passes illustration_bucket_list_empty. Messages inbox gained a proper empty state
("Your private conversation starts here") with illustration_messages_empty. Added
BrandIllustration() helper (theme-safe rounded tile / tile=false for transparent art)
for the upcoming header/hero placements. Verified live both themes: rounded illustration
tile reads cleanly on dark (card) and light (white card on blush); 0 FATAL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
I-001: getOutcomes() did a bare collection list .get() on couples/{cid}/outcomes,
which firestore.rules denies (reads allowed only for dayKey in day_0/30/60/90) ->
always PERMISSION_DENIED, swallowed to emptyList(). Now scopes the query with
whereIn(FieldPath.documentId(), OUTCOME_DAY_KEYS) so it satisfies the rule.
I-002 (found while fixing I-001): toOutcomeScores() cast values to Map<String,Int>,
but Firestore returns integer fields as Long on Android -> ClassCastException ->
scores dropped (same shape submitOutcomeCallable writes, so the real path was broken
too). Now coerces (value as? Number)?.toInt().
Verified live: 0 outcomes PERMISSION_DENIED after relaunch; seeded a day_0 baseline
(int64) -> "Your Progress" shows "Baseline recorded" (was "No baseline yet"). Seed
removed, couple baseline restored (0 outcomes, 0 active sessions). Both pending one
re-QA confirmation round before pruning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simultaneous game start by both partners created two divergent active sessions (TOCTOU: a
non-transactional check-then-create in GameSessionManager.startGameWithCouple). Each partner
ended up in a separate session with different questions → no shared reveal.
Fix: QuestionSessionRepository.startSessionAtomically runs a Firestore transaction on a
per-couple pointer doc (couples/{cid}/sessions/_active). It reads the pointer (+ the pointed
session) and either returns AlreadyActive (caller joins the existing session) or atomically
creates the new session and re-points the lock. Concurrent starts contend on the one pointer,
so the loser's transaction retries, sees the now-set pointer, and joins instead of duplicating.
The pointer self-heals (checks the pointed session's status) so no clear-on-finish is needed,
and it carries no status/completedAt so it's invisible to the active/history queries.
GameSessionManager routes all 7 games through it. firestore.rules adds member-write for
sessions/_active (deployed).
Verified live on both emulators: atomic create → 1 session + pointer; sequential 2nd start →
joins (1 session); literal parallel-tap race → 1 session (was 2); 0 FATAL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The paywall ErrorState rendered uiState.error verbatim, surfacing developer-facing billing/RC SDK
text ('There was a credentials issue. Check the underlying error for more details.') to users.
Now always shows friendly copy. Verified live: free user -> paywall -> 'We couldn't load
subscription options right now. Check your connection and tap to try again.' (no raw error).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The generic WaitingForPartner screen only exited when the session became null, so a partner who
landed there for an async game they could actually play (every current game is async — both play
on their own device) was stuck waiting forever, recoverable only via Back to Games. Now the screen
resolves the active session's game route and offers a primary 'Join the game' action that drops the
user into the game (which auto-joins the session). Deterministic repro: QA starts How Well, Sam
opens a different game -> one-game lock routes Sam to WaitingForPartner -> 'Join the game' -> How
Well guess intro. Verified live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
partner_started_game / partner_completed_part now route to the specific game route
(gameRouteForType(payload.gameType)) so the game screen auto-joins the couple's active
session — fulfilling the 'Tap to join!' promise. Server already sends game_type in the FCM
data; client now parses it (AppMessagingService + MainActivity) into PartnerNotificationPayload
and routeFor maps it. game_results_ready stays on the hub pending a server change to also send
game_session_id (completed sessions aren't returned by getActiveSession, so the plain game route
would show setup). Verified live: backgrounded partner tapped the start-game push -> opened This
or That at 1/5 (joined), not the hub.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
observeCapsules swallowed listener errors (return@), so on PERMISSION_DENIED the flow never
emitted or closed and Memory Lane hung on its loading heart forever. Now close(err)s the
flow -> the ViewModel's existing onFailure -> ERROR state with Retry. (Root cause that
masked D-001.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Added PARTNER_UNPAIRED type for the two real 'you are unpaired' pushes -> Home, where the
now-solo user gets the Invite CTA (matches body 'Tap to create a new invite'). Documented
that invite_created (server audit log, read:true) and spki (a crypto key-format string in
the RevenueCat webhook, not a notification) are false positives needing no routing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-person tiles showed '$total private' (e.g. '5 private'), contradicting the caption's
'N kept private' (e.g. '2 kept private'). Tiles now read just 'Private' (your individual
answers always stay private); the caption keeps the real shared/kept breakdown. Verified:
'You: Private / Sam: Private' + 'N shared, M kept private', no contradiction.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Threaded showPremiumBadge=!hasPremium into DesireSyncCard/MemoryLaneCard and gated the
lock badge behind it. The feature was already accessible (A-001) — only the static badge
was misleading. Verified: with couple premium the Play hub shows no Premium badge on them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve the active session's gameType to its resume route (gameRouteFor) and carry it on
HomeAction.gameRoute / HomeUiState.waitingGameRoute; HomeActionTarget.Game now navigates
there (fallback Play hub). Each game screen auto-joins the couple's active session on open,
so the Home 'Play now' CTA drops the user straight into the actual waiting game.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DesireMatchCard used a hardcoded dark plum (Color(0xFF3D1F2E)) for the shared-desire
text -> readable on the light card in light mode, but dim/low-contrast on the dark-tinted
card in dark mode. Switched to MaterialTheme.colorScheme.onSurface so it adapts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The screen renders its own header (title + 'Pick a series…' subtitle + back) for both
the pick and active views, so the nav-scaffold app bar drew a SECOND identical header +
back arrow on top. Removed it from shellBackRoutes -> single header, single back.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Navigating to Home from any entry route (onboarding/profile/pair/login/signup/forgot)
now resets the stack (popUpTo(0) inclusive) so Home is the back-stack root. Previously
the graph start (ONBOARDING) lingered under Home, so system Back from Home walked
backward into the onboarding carousel -> welcome/login, making a signed-in user look
logged out. Verified: Back from Home now exits the app to the launcher.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client fromRemoteType mapped only daily_question_reminder/challenge_waiting; functions
send daily_question/challenge_day_ready too. Tapping those now deep-links to Today /
Connection Challenges. Also records Pass C (main screens clean) + Pass D (security clean).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route all feature gates (Play hub, Desire Sync, Memory Lane, Connection Challenges,
Question Packs, wheel category/spin/history) through CouplePremiumChecker instead of
per-user EntitlementChecker. CouplePremiumChecker now exposes isPremium()/hasPremium()
that resolve the partner internally (self OR partner premium). Verified live: Sam premium →
QA enters Desire Sync; both free → QA → paywall.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the orphaned in-thread QuestionDiscussionThread (which wrote to question_threads
and sent no notifications) with a 'Discuss this together' button that opens the conversation
(q_<questionId>), same as the daily flow — so pack discussions are notified and appear in
the Messages inbox.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CouplePremiumChecker ORs self.isPremium with a live read of the partner's entitlement
doc (reactive). Composer photo/camera/voice buttons + keyboard GIF/sticker insert + the
reaction action gate on canSendMedia: locked buttons show a lock badge and route to the
existing PaywallScreen (with a chat_media paywall analytics event). Text/viewing/receiving
stay free. Rules: paired partner may read the entitlement doc. Verification pending deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Debounced typing flag (typing:{uid:ts}) on the conversation doc, cleared on stop/send/
leave; partner sees 'typing…' with a ~6s TTL safety net (ticker-driven auto-hide). Rules
allow members to write the typing field. Live verification pending the Phase B deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Long-press a message for a reaction bar (heart/laugh/thumb/wow/sad/fire), Copy (text),
and Delete (author). Reactions stored as a reactions:{uid:emoji} map; delete sets a
'deleted' tombstone ('This message was deleted') and updates the inbox preview if it was
last. Rules: any member may change only reactions; author may set only deleted. Live
verification pending the Phase B rules deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Observe the partner's last-read timestamp on the conversation doc; show 'Seen · time'
under the last own message once the partner has read past it. No rules change (reuses reads).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Voice bubbles show a determinate progress bar + elapsed time that advance during
playback (polling MediaPlayer position); recording auto-stops and sends at 2 minutes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pagination: observeMessages(limit) uses limitToLast(N); a single live window grows
by a page when scrolled to the top (keeps just-sent messages in view, no merge needed).
- Send feedback: 'Sending photo/voice…' chip above the composer with retry + dismiss on
failure, plus a snackbar; media uploads fail fast when offline (connectivity pre-check +
30s Storage retry cap) instead of a stuck spinner.
- Auto-scroll to bottom only on new messages when near the bottom (never on load-older).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Show a muted clock time under the last bubble of each sender-run (side-aligned),
and a centered Today/Yesterday/date pill between messages from different days.
Falls back to now for a just-sent message whose server timestamp is unresolved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Downscale to 1600px + JPEG 80% on the gallery/camera send path only; keyboard
GIFs/stickers/Bitmoji stay untouched to preserve animation/transparency. Applies
EXIF rotation and falls back to the original bytes on any failure.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ChatComponents: voice recording bar (mic → record → send/cancel), voice playback with MediaPlayer, GIF/WebP via coil-gif + ImageDecoderDecoder, keyboard content receiver for stickers/Bitmoji
- ConversationViewModel: sendVoice wired through
- ConversationScreen: onSendVoice passed to ChatComposer
- MessagesInboxScreen: list of conversations with last-message preview, tap to open
- MessagesInboxViewModel: observes conversations from Firestore
- ConversationScreen: E2E-encrypted messaging with send/receive, image support
- ConversationViewModel: send message, observe messages, active conversation tracking
- ChatComponents: reusable message bubble, input bar, encrypted image rendering
- NotificationRateLimiter: 20 partner/day, 100/week (was 2/4 — too tight for game activity)
- firestore.rules: messages create allows type=image with mediaUrl or type=text with ciphertext
- storage.rules: chat_media path with 15MB cap
- .gitignore: ClaudeReport.md, docs/img
- MessageBubbleOverlay: drag-to-dismiss target at bottom-center, no 12s auto-timeout (persists until read)
- MessageBubbleController: dismissFor clears bubble when its conversation is opened
- ActiveThreadMonitor: calls dismissFor on enter, clearing the bubble for that thread
- PartnerHomeViewModel: loads partner photoUrl alongside name
- PartnerIdentityCard: shows AsyncImage when photoUrl available, fallback to initial letter
- MainActivity: deepLinkRouteFromIntent resolves FCM data extras to navigation routes; pendingDeepLink state for onNewIntent
- AppNavigation: LaunchedEffect waits for HOME route before navigating deep link (fixes race with onboarding)
- onMessageWritten: includes author displayName + photoUrl in notification payload
- MainActivity: request POST_NOTIFICATIONS on TIRAMISU+, register FCM token when user signs in
- AppMessagingService: foreground chat messages show draggable bubble instead of OS notification
- MessageBubbleController/Overlay: new in-app chat-head that drifts over all screens, tap to open
- PartnerNotificationManager: GAME_RESULTS_READY type with proper copy, partner_finished_game maps to it
- onGameSessionUpdate: notify BOTH partners on completion (not just the non-starter), fix starter name in notification