Commit Graph

277 Commits

Author SHA1 Message Date
null 4eed0a8115 feat(premium): couple-shared unlock notification + reveal retry + users update allowlist + brand glyphs
- New Cloud Function: onEntitlementChanged (Firestore onWrite on entitlements/premium) — edge-triggered inactive→active, notifies the OTHER partner so couple-shared unlock isn't silent
- New notification type SUBSCRIPTION_CHANGED → routes to SUBSCRIPTION
- AnswerRevealViewModel: re-issue markRevealed if best-effort failed (offline/transient) so partner_opened_answer push eventually fires
- firestore.rules: harden users/{uid} update allowlist (defense-in-depth; no live hole)
- 18 new brand glyph vector drawables (drawable-nodpi/)
- SettingsScreen / PlayHubScreen / WaitingForPartnerScreen: swap Material icons for new brand glyphs
- ClaudeQA docs + Future.md updated
2026-06-27 16:35:41 -05:00
null 9f09ebbc67 chore: R12 working tree — QA docs, brand illustration updates, date-match paywall routing, theme tweaks 2026-06-27 15:34:38 -05:00
null 2cd0af65a8 chore: working tree changes — QA docs, app tweaks, Cloud Functions updates 2026-06-27 13:31:09 -05:00
null 9c84c36443 fix(qa): R10 fix phase — 5 P2 bugs fixed (C-HOME-001, C-NAV-002, C-NAV-003, C-PW-001, C-SEC-001) 2026-06-27 10:34:26 -05:00
null 38fdc6d2cc feat(notif): foreground game-start banner + bold Home waiting hero — join specific game from both 2026-06-26 20:04:11 -05:00
null b9b15604ef fix(games): notification deep-link lands in active game — singleTop + server-first read (E-GAME-001) 2026-06-26 12:41:17 -05:00
null f7418df700 feat(notif): add PARTNER_OPENED_ANSWER notification type + deep-link routing 2026-06-26 12:40:54 -05:00
null df32229f3b feat(answers): replace sealed-key exchange with couple-key encryption (schemaVersion 2) — reveal on both-answered, no key handshake 2026-06-26 12:40:49 -05:00
null fe104b4a41 brand(art): add 12 dark-theme illustration variants for night mode 2026-06-26 11:27:36 -05:00
null 5868d06421 brand(art): wire Delete account calm-goodbye illustration (A12)
DeleteAccountScreen gains illustration_account_deletion_goodbye centered above the copy —
a soft box releasing hearts (no alarm imagery), making the goodbye respectful and on-brand.
Verified live on dark; 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:59:53 -05:00
null 9b1e946ed8 brand(art): pairing-success hero -> A1 celebration; Security header -> A11 privacy-lock
PairingSuccessScreen replaces the white-keyhole app-icon chip joining the two partner
avatars with the illustration_pairing_success celebration (transparent, tile=false,
keeps the spring + pulse) so the "you're connected" beat shows the mark resolving with a
burst of hearts. SecurityScreen gains the illustration_privacy_recovery scene at the top.
Verified live: Security on dark (warm privacy-lock, not cold vault); A1 confirmed in the
debug gallery (transparent floats cleanly). 0 FATAL. Pairing-success needs a fresh pairing
to see in situ; A1 render proven via gallery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:57:26 -05:00
null 86679752b0 brand(art): wire Connection Challenges header (A3 banner) + Quiet hours (A9)
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>
2026-06-26 09:53:28 -05:00
null fb4620559b brand(art): wire Date Match A5 (empty + it's-a-match) + Memory Lane A4; add all new art to debug gallery
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>
2026-06-26 09:48:13 -05:00
null 5d74858679 brand(art): wire Answer History (A2) + Past Games (A10) empty illustrations
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>
2026-06-26 09:40:08 -05:00
null 4aec224f0d brand(art): wire Messages-empty (A8) + Bucket List-empty (A6); add BrandIllustration helper
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>
2026-06-26 09:36:53 -05:00
null 077a408785 brand(art): add 12 generated illustrations to drawable-nodpi; gitignore brand source art
Phase 0 of wiring the generated brand art (full-res per request). Adds A1 pairing,
A2 answer-history, A3 challenges header, A4 memory-lane capsule, A5 date-match
empty+success, A6 bucket-list, A8 messages, A9 quiet-hours, A10 past-games,
A11 privacy-recovery, A12 account-deletion to res/drawable-nodpi. Source working art
(docs/brand/{generated-art,sources,exports}) gitignored — only app copies committed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:29:42 -05:00
null ab29f6b12f fix(outcomes): restore Your Progress read — scope query to allowed dayKeys + coerce Long scores (I-001, I-002)
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>
2026-06-25 23:58:37 -05:00
null 23dd6a75e8 fix(games): atomic session start to prevent duplicate sessions on concurrent start (F-RACE-001)
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>
2026-06-25 21:43:06 -05:00
null f47c8e2b64 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>
2026-06-25 16:00:58 -05:00
null 95cad84cb5 brand: loading state, themes, manifest, art preview, pairing screen updates 2026-06-25 15:24:46 -05:00
null fed91dbe46 brand: finalize app icon, brand docs, onboarding visuals, feature graphic 2026-06-25 14:52:21 -05:00
null 520eea2236 brand: update launcher foreground, feature graphic, auth visuals, brand docs 2026-06-25 14:48:57 -05:00
null 334cb079fa brand: update app icon, iOS assets, Android drawables, brand docs (Pass H) 2026-06-25 14:34:27 -05:00
null aaab768cb0 fix(notif): deep-link results-ready pushes to per-session results/replay screen (E-003) 2026-06-25 12:35:49 -05:00
null d99fa6c6ea fix(paywall): show friendly plan-load error, not raw SDK message (A-OBS)
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>
2026-06-25 12:35:49 -05:00
null f1549c642c fix(games): add 'Join the game' escape to WaitingForPartner screen (B-004)
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>
2026-06-25 12:35:49 -05:00
null 1b9d8cf8dc fix(notif): game pushes deep-link into the waiting game, not the Play hub (E-003)
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>
2026-06-25 12:35:49 -05:00
null 8e08823e83 fix(memorylane): propagate snapshot-listener errors so the screen doesn't hang (F-OBS P3)
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>
2026-06-25 10:14:17 -05:00
null 46010508a9 fix(notifications): route partner_left/partner_deleted_account to Home (E-002 P3)
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>
2026-06-25 10:14:16 -05:00
null f63188d97a fix(desiresync): clearer privacy counts on reveal (B-003 P3)
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>
2026-06-25 10:14:15 -05:00
null f8ae15a41b fix(play): hide Premium badge on Desire Sync/Memory Lane cards when couple has premium (A-003 P3)
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>
2026-06-25 10:14:14 -05:00
null a94f44d3ec fix(home): 'Play now' resumes the waiting game, not the generic hub (B-002 P2)
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>
2026-06-25 09:58:26 -05:00
null 1fe4dea9c1 fix(desiresync): theme-aware reveal text for dark mode (C-DS-001 P2)
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>
2026-06-25 09:58:25 -05:00
null edc00d2a5f fix(nav): drop Connection Challenges from shellBackRoutes (C-CC-001 P2)
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>
2026-06-25 09:58:24 -05:00
null ebd3b2ed1f fix(nav): clear onboarding/auth back stack on entry->Home (C-NAV-001 P1)
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>
2026-06-25 09:37:36 -05:00
null ce12abb1a6 fix(notifications): route daily_question + challenge_day_ready taps (E-001 P2)
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>
2026-06-24 21:07:32 -05:00
null e8892a9669 fix(premium): couple-shared premium everywhere (A-001)
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>
2026-06-24 20:52:22 -05:00
null 6580299f05 feat(packs): route pack Discuss to the unified conversation
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>
2026-06-24 18:55:40 -05:00
null 7a9ff31ae6 feat(chat): couple-shared premium gating for sending media + reactions
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>
2026-06-24 18:52:50 -05:00
null f29d4699ca feat(chat): typing indicator
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>
2026-06-24 18:47:39 -05:00
null 5b9596e042 feat(chat): message reactions + delete (unsend) via long-press menu
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>
2026-06-24 18:44:13 -05:00
null 7f1b938aa5 feat(chat): read receipts (Seen) via the conversation reads map
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>
2026-06-24 18:38:42 -05:00
null 3aa182a466 feat(chat): voice playback progress bar + 2-min recording cap
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>
2026-06-24 18:35:32 -05:00
null cfea8f0d41 feat(chat): send/upload feedback + message pagination
- 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>
2026-06-24 18:32:01 -05:00
null 3544b7a84a feat(chat): message timestamps + day separators
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>
2026-06-24 18:14:05 -05:00
null 8a68ae3107 feat(chat): compress gallery/camera photos before encryption (EXIF-safe, skip GIFs)
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>
2026-06-24 18:10:24 -05:00
null 11a4c7deda feat(chat): RichContentTextField for keyboard GIF/sticker/Bitmoji insertion, tap-to-enlarge images
- RichContentTextField: AppCompatEditText wrapper with OnReceiveContentListener for image/* MIME types, enables Gboard GIF/sticker/Bitmoji tabs
- ChatComponents: replace OutlinedTextField with RichContentTextField, remove ExperimentalFoundationApi opt-in, add tap-to-enlarge full-screen image viewer with aspect-ratio sizing
2026-06-24 17:37:13 -05:00
null dbf7ae662b chore(config): coil-gif dependency, RECORD_AUDIO permission, firestore rules allow voice type
- build.gradle.kts: add coil-gif 2.7.0 for animated GIF/WebP support
- AndroidManifest: RECORD_AUDIO permission + microphone feature (optional)
- firestore.rules: messages create allows 'voice' type alongside 'image', accepts durationMs field
2026-06-24 16:34:53 -05:00
null 3ad725ca8a feat(voice+media): voice recording/playback UI, GIF/sticker support, keyboard content receiver
- 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
2026-06-24 16:34:48 -05:00
null c20745e82a feat(voice): data layer — voice message model, sendVoiceMessage, durationMs field
- QuestionMessage: add durationMs, isVoice, type supports 'voice'
- ConversationRepository: sendVoiceMessage(audioBytes, durationMs)
- FirestoreConversationDataSource: sendVoiceMessage encrypts + uploads audio, decrypts durationMs
- ConversationRepositoryImpl: delegates sendVoiceMessage
2026-06-24 16:34:38 -05:00