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
This commit is contained in:
null 2026-06-27 16:35:41 -05:00
parent 9f09ebbc67
commit 4eed0a8115
32 changed files with 518 additions and 109 deletions

View File

@ -1,13 +1,14 @@
# Closer — Branding & Artwork Review (Pass H) # Closer — Branding & Artwork Review (Pass H)
Living deliverable for the **Branding pass**. It captures, screen by screen, where Closer could carry more of its Living status document for Closer's brand artwork pass. It records which illustrations/glyphs are live, which surfaces
brand, and gives **ready-to-paste ChatGPT image-generation prompts** for the artwork worth adding. The user generates should reuse existing assets, and which brand issues belong in implementation or QA rather than image generation.
the images; this file only describes them. Every prompt reuses the **House Style** block below so all new art matches
the existing artwork (`docs/brand/visual-identity.md` + `docs/brand/asset-system.md` + the shipped `illustration_*` / **Current state:** no active image-generation prompts remain. A1-A12 illustrations and the G/G2 glyph sets exist; the
`pack_art_*` / `particle_*` assets). remaining brand work is theme polish, reuse of existing assets, or QA verification.
> Branding **defects** (off-brand color, clipped/low-contrast art) → `ClaudeReport.md`. Pure "could be warmer / feature" > Branding **defects** (off-brand color, clipped/low-contrast art) → `ClaudeReport.md`. Pure "could be warmer / feature"
> ideas → `Future.md` `## QA`. New art to create → here. > ideas → `Future.md` `## QA`. Only add new prompts here when a future QA pass proves an existing/code-native treatment
> cannot carry the brand.
--- ---
@ -16,7 +17,7 @@ R10 visual sweep doubled as the Pass-H existing-art integration check: Today (pa
Paywall (couple illustration), Security (padlock), Memory Lane / Date / Bucket-List empties, Home cards — **all render Paywall (couple illustration), Security (padlock), Memory Lane / Date / Bucket-List empties, Home cards — **all render
on-brand, in-context, both themes, no clipping/placeholder/off-brand issues** (any defect would be a `ClaudeReport.md` on-brand, in-context, both themes, no clipping/placeholder/off-brand issues** (any defect would be a `ClaudeReport.md`
bug; none found). The new game-alert surfaces (`GamePromptBanner`, `GameWaitingHeroCard`) use the brand purple gradient bug; none found). The new game-alert surfaces (`GamePromptBanner`, `GameWaitingHeroCard`) use the brand purple gradient
+ PlayArrow glyph and read as intentional action banners — no illustration warranted. No new art to add this round. + PlayArrow glyph and read as intentional action banners, so no illustration is warranted there.
## This or That gameplay brand plan (Codex QA, 2026-06-27) ## This or That gameplay brand plan (Codex QA, 2026-06-27)
Live review used a dedicated QA launcher/device (`CloserCodexQA`) with a fresh admin-created test couple Live review used a dedicated QA launcher/device (`CloserCodexQA`) with a fresh admin-created test couple
@ -60,7 +61,7 @@ motif reads like a technical diagram instead of a warm private ritual for two.
- Dark mode feels intentionally Closer-branded: aubergine/lavender/pink, soft and private, not a placeholder diagram. - Dark mode feels intentionally Closer-branded: aubergine/lavender/pink, soft and private, not a placeholder diagram.
- Logs are checked after opening a game from notification/deep link before assuming the route worked. - Logs are checked after opening a game from notification/deep link before assuming the route worked.
## ✅ WIRED INTO ANDROID (2026-06-26 art drop) — generated illustrations live ## Generated Art Live
The generated art (source in gitignored `docs/brand/generated-art/`, copied full-res to The generated art (source in gitignored `docs/brand/generated-art/`, copied full-res to
`app/src/main/res/drawable-nodpi/`) is now wired into the app via the shared `EmptyState` `app/src/main/res/drawable-nodpi/`) is now wired into the app via the shared `EmptyState`
(rounded-tile, theme-safe) and a new `ui/components/BrandIllustration.kt` helper: (rounded-tile, theme-safe) and a new `ui/components/BrandIllustration.kt` helper:
@ -79,15 +80,38 @@ The generated art (source in gitignored `docs/brand/generated-art/`, copied full
| A11 `privacy_recovery` | SecurityScreen header | **live dark** | | A11 `privacy_recovery` | SecurityScreen header | **live dark** |
| A12 `account_deletion_goodbye` | DeleteAccountScreen header | **live dark** | | A12 `account_deletion_goodbye` | DeleteAccountScreen header | **live dark** |
All 12 also live in the debug **Art preview** gallery (Settings → Art preview) for both-theme verification. All 12 also live in the debug **Art preview** gallery (Settings → Art preview) for both-theme verification. A7 pack art is
**Not done:** A7 pack art = **N/A** (all 10 packs already have `pack_art_*`). **G-set glyph art generated **N/A** because all 10 question packs already have `pack_art_*`.
2026-06-27 and ready to add** from `docs/brand/generated-art/glyphs/`; app wiring is still pending.
A1-A12 prompts are complete and should not be regenerated. Empty/match/pairing states that need empty-or-new data weren't A1-A12 prompts are complete and should not be regenerated. Empty/match/pairing states that need empty-or-new data were
reachable on the baseline couple — render path proven via the shared tile + gallery. Commits `077a408`→`5868d06` on `dev`. not all reachable on the baseline couple, but their render path is proven through the shared tile + gallery.
## Glyph Status
The original G-set plus G2 set are copied into `app/src/main/res/drawable-nodpi/glyph_*.xml`. Source SVGs live in
`docs/brand/generated-art/glyphs/source-svg/`; Android handoff vectors live in
`docs/brand/generated-art/glyphs/android-vector/`.
**Wired + verified live (13 of 17):**
- Play hub cards: `paired_cards`, `how_well`, `sealed_answer`, `connection_challenge`, `memory_capsule`,
`date_card_heart`, `question_packs`, `bucket_list`, `past_games`.
- WaitingForPartner per-game glyphs: `spin_wheel`, `paired_cards`, `how_well`, `sealed_answer`.
- Settings rows: `couple_premium`, `privacy_lock`, `delete_account`.
**Generated but intentionally unused for now (4 of 17):**
- `closer_mark` — notifications already use `ic_notification_closer`.
- `daily_card` — Today uses its hero illustration.
- `quiet_hours_moon` — Quiet hours uses `illustration_quiet_hours`.
- `export_data` — no export-data row exists yet.
White monochrome vectors are re-tinted by `Icon(tint = ...)` and loaded via `ImageVector.vectorResource(...)`.
`glyph_paired_cards` is also the preferred motif for the This-or-That backdrop redesign tracked as C-DARK-UI-001.
--- ---
## House Style (paste this at the top of EVERY image prompt) ## Prompt Style Reference
Use this only when a future pass creates a new prompt. Existing completed art should not be regenerated.
> Flat 2D pastel vector **illustration** in the "Closer" couples-app style: soft rounded shapes, **no harsh outlines**, > Flat 2D pastel vector **illustration** in the "Closer" couples-app style: soft rounded shapes, **no harsh outlines**,
> gentle smooth gradients, calm and intimate. **Palette only:** aubergine `#24122F`, deep purple `#56306F`, lavender > gentle smooth gradients, calm and intimate. **Palette only:** aubergine `#24122F`, deep purple `#56306F`, lavender
@ -123,9 +147,9 @@ future QA pass finds a specific missing surface or replacement-worthy defect.
--- ---
## Screen-by-screen audit ## Screen-By-Screen Audit
Legend: ✅ on-brand / no art needed · add/wire art · 🎨 new art to generate · 🔤 brand-copy/color touch Legend: ✅ on-brand / no art needed · reuse/wire existing art · 🔤 brand-copy/color/code touch
| Screen / surface | Current brand state | Opportunity | | Screen / surface | Current brand state | Opportunity |
|---|---|---| |---|---|---|
@ -135,18 +159,18 @@ Legend: ✅ on-brand / no art needed · add/wire art · 🎨 new art to gene
| Create profile — name / sex / photo | plain steps | 🔤 light: small step glyphs; ✅ otherwise (forms stay clean) | | Create profile — name / sex / photo | plain steps | 🔤 light: small step glyphs; ✅ otherwise (forms stay clean) |
| Pair: invite (create code) | `illustration_couple_invite` wired | ✅ | | Pair: invite (create code) | `illustration_couple_invite` wired | ✅ |
| Pair: accept code / pairing success | `illustration_pairing_success` wired | ✅ | | Pair: accept code / pairing success | `illustration_pairing_success` wired | ✅ |
| Home (paired) | cards, warm copy | ✅ good; ensure card icons use brand glyphs 🔤 | | Home (paired) | cards, warm copy | ✅ good |
| Home (unpaired "bring your person in") | couple art present | ✅ on-brand | | Home (unpaired "bring your person in") | couple art present | ✅ on-brand |
| Today / daily question | clean card | ✅; reveal moment is the place for art (below) | | Today / daily question | clean card | ✅; reveal moment is the place for art (below) |
| Answer reveal (mutual) | `illustration_reveal_celebration` wired | ✅ | | Answer reveal (mutual) | `illustration_reveal_celebration` wired | ✅ |
| Answer history | `illustration_answer_history_empty` wired | ✅ | | Answer history | `illustration_answer_history_empty` wired | ✅ |
| Play hub | card list, game glyphs | ✅; verify each game card has a consistent brand glyph 🔤 | | Play hub | game glyphs wired | ✅ |
| This or That (setup/play) | light buttons good; dark prompt backdrop off-brand | 🔤 replace two-circle/line motif + theme option colors | | This or That (setup/play) | light buttons good; dark prompt backdrop off-brand | 🔤 replace two-circle/line motif + theme option colors |
| This or That / How Well / Desire Sync **results** | score + rows | small celebration header art (reuse `reveal_celebration`); particles on high match | | This or That / How Well / Desire Sync **results** | score + rows | reuse `reveal_celebration`; particles on high match |
| How Well / Desire Sync intro | icon + copy | ✅ | | How Well / Desire Sync intro | icon + copy | ✅ |
| Spin the Wheel | nice wheel art | ✅ wheel is on-brand | | Spin the Wheel | nice wheel art | ✅ wheel is on-brand |
| Wheel complete / results | text reveal | celebration header (reuse particles) | | Wheel complete / results | text reveal | celebration header (reuse particles) |
| Connection Challenges (series list) | `illustration_connection_challenges_header` wired | ✅; verify per-series glyph consistency 🔤 | | Connection Challenges (series list) | `illustration_connection_challenges_header` wired | ✅ |
| Connection Challenges (active day) | clean | 🔤 small streak/heart glyph; ✅ otherwise | | Connection Challenges (active day) | clean | 🔤 small streak/heart glyph; ✅ otherwise |
| Memory Lane (list) | `illustration_memory_lane_capsule` empty state wired | ✅ | | Memory Lane (list) | `illustration_memory_lane_capsule` empty state wired | ✅ |
| Memory Lane (sealed capsule) | lock + date | optional: reuse capsule art on sealed-card detail, no new art | | Memory Lane (sealed capsule) | lock + date | optional: reuse capsule art on sealed-card detail, no new art |
@ -160,29 +184,25 @@ Legend: ✅ on-brand / no art needed · add/wire art · 🎨 new art to gene
| Past Games (empty/list) | `illustration_past_games_empty` wired | ✅ | | Past Games (empty/list) | `illustration_past_games_empty` wired | ✅ |
| Your Progress / Activity | stats | 🔤 brand-colored charts; reuse `streak_milestone` for milestones | | Your Progress / Activity | stats | 🔤 brand-colored charts; reuse `streak_milestone` for milestones |
| Paywall / Subscription | couple art present | ✅ strong (couple illustration + “one subscription for both”) | | Paywall / Subscription | couple art present | ✅ strong (couple illustration + “one subscription for both”) |
| WaitingForPartner | category glyph + copy | ✅ on-brand; ensure glyph per game type | | WaitingForPartner | per-game glyphs + copy | ✅ |
| Settings + sub-pages | dense lists | ✅ keep clean — **no illustrations**; brand via section headers/color only 🔤 | | Settings + sub-pages | dense lists | ✅ keep clean — **no illustrations**; brand via section headers/color only 🔤 |
| Security / Recovery phrase | `illustration_privacy_recovery` wired | ✅ | | Security / Recovery phrase | `illustration_privacy_recovery` wired | ✅ |
| Privacy & Terms | text | 🔤 small lock glyph header | | Privacy & Terms | settings row uses `glyph_privacy_lock` | ✅; page can stay text-first |
| Delete account | `illustration_account_deletion_goodbye` wired | ✅ | | Delete account | `illustration_account_deletion_goodbye` wired | ✅ |
| Quiet hours (settings) | `illustration_quiet_hours` wired | ✅ | | Quiet hours (settings) | `illustration_quiet_hours` wired | ✅ |
| Notifications (system) | G-set glyph art generated, not wired | wire `docs/brand/generated-art/glyphs/android-vector/glyph_*.xml` | | Notifications (system) | `ic_notification_closer` used | ✅ no glyph swap needed |
--- ---
## Art to generate — status ## Generation Backlog
A1-A12 were generated and wired into Android. A7 is not needed because all 10 question packs already have `pack_art_*`. **None.** Do not regenerate completed illustration or glyph art unless a future QA pass logs a specific defect that
G-set glyphs were generated 2026-06-27 and are ready to add from `docs/brand/generated-art/glyphs/`. There are no active requires replacement.
art-generation prompts right now.
Do not regenerate completed illustration art unless a future QA pass logs a specific defect that requires replacement. **Generated glyph files:** `glyph_closer_mark`, `glyph_paired_cards`, `glyph_daily_card`, `glyph_sealed_answer`,
Always clean up completed art items: once an asset is generated, wired, and verified, remove its prompt from this active
backlog and update the audit table/status notes so only unfinished work remains.
**Generated G-set files:** `glyph_closer_mark`, `glyph_paired_cards`, `glyph_daily_card`, `glyph_sealed_answer`,
`glyph_memory_capsule`, `glyph_date_card_heart`, `glyph_quiet_hours_moon`, `glyph_couple_premium`, `glyph_memory_capsule`, `glyph_date_card_heart`, `glyph_quiet_hours_moon`, `glyph_couple_premium`,
`glyph_export_data`, `glyph_delete_account`. `glyph_export_data`, `glyph_delete_account`, `glyph_how_well`, `glyph_connection_challenge`, `glyph_question_packs`,
`glyph_bucket_list`, `glyph_past_games`, `glyph_spin_wheel`, `glyph_privacy_lock`.
--- ---
@ -193,4 +213,3 @@ backlog and update the audit table/status notes so only unfinished work remains.
Android-ready vectors in `docs/brand/generated-art/glyphs/android-vector/` for app wiring. Android-ready vectors in `docs/brand/generated-art/glyphs/android-vector/` for app wiring.
- After adding art, re-run Pass C (visual, light + dark) on those screens to confirm contrast + no clipping, and - After adding art, re-run Pass C (visual, light + dark) on those screens to confirm contrast + no clipping, and
re-export store graphics per `docs/brand/visual-identity.md` if the palette/mark changed. re-export store graphics per `docs/brand/visual-identity.md` if the palette/mark changed.
</content>

View File

@ -1,5 +1,7 @@
# Claude QA Report — Full-App QA (living report) # Claude QA Report — Full-App QA (living report)
> **Verdict addendum (2026-06-27): ad hoc DARK-MODE UI/brand review on dedicated Codex emulator COMPLETE.** Built + installed the current debug APK on my own `CloserCodexQA` emulator (`emulator-5558`), forced system dark mode, created a fresh real paired couple through the app invite flow, and swept profile/onboarding, unpaired invite, paired Home, Play, This-or-That, Settings, Notifications, Paywall, Messages, and Today. **Button text is generally readable** across profile/Home/Settings/Notifications/Messages/Paywall, but the sweep found **1 open P2**: This-or-That active gameplay has low-contrast dark option text and an off-brand diagonal/circle backdrop crossing the prompt. Also found **2 open P3s**: first-launch check-in modal label/value collision and recurring bottom-inset clipping on scroll content near nav/gesture areas. Logs checked after navigation/game entry: **0 app FATAL/ANR/force-finish**; only uiautomator/system noise plus a non-crashing BillingClient unbind warning.
> **Verdict (2026-06-27): R11 confirmation round COMPLETE — FLAWLESS (0 open P0P2). Fixed the last open P2 (C-DARKART-001 — dark art now follows the in-app theme) + the open P3 (C-ART-EDGE-001 — art feathers into the surface), both in the shared `BrandIllustration`/`EmptyState` helpers, verified live on both decoupled theme directions (system-light+app-Dark → dark aubergine art; system-dark+app-Light → light pastel art), 0 FATAL. Re-confirmed all 5 R10 P2 fixes hold (C-HOME-001 single card · C-NAV-002 wheel-back popUpTo present · C-NAV-003 single app bar live · C-PW-001 dark paywall pills legible live · C-SEC-001 recovery row active for accepter live) → pruned. Entrypoint launch-integrity smoke green (splash-crash class clean on the fresh APK). Only remaining: 2 freshly-fixed art items pending 1 confirm + J-OBS (P3 touch targets). Art fixes in working tree — user commits.** > **Verdict (2026-06-27): R11 confirmation round COMPLETE — FLAWLESS (0 open P0P2). Fixed the last open P2 (C-DARKART-001 — dark art now follows the in-app theme) + the open P3 (C-ART-EDGE-001 — art feathers into the surface), both in the shared `BrandIllustration`/`EmptyState` helpers, verified live on both decoupled theme directions (system-light+app-Dark → dark aubergine art; system-dark+app-Light → light pastel art), 0 FATAL. Re-confirmed all 5 R10 P2 fixes hold (C-HOME-001 single card · C-NAV-002 wheel-back popUpTo present · C-NAV-003 single app bar live · C-PW-001 dark paywall pills legible live · C-SEC-001 recovery row active for accepter live) → pruned. Entrypoint launch-integrity smoke green (splash-crash class clean on the fresh APK). Only remaining: 2 freshly-fixed art items pending 1 confirm + J-OBS (P3 touch targets). Art fixes in working tree — user commits.**
> **📖 Architecture reference:** see [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md). Most fixed-and-pruned IDs above are documented in its [Known landmines and recent fixes](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) section — read before re-touching the affected area. > **📖 Architecture reference:** see [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md). Most fixed-and-pruned IDs above are documented in its [Known landmines and recent fixes](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) section — read before re-touching the affected area.
@ -10,6 +12,7 @@
> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`. > to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
## Run-state (current) ## Run-state (current)
- **Ad hoc dark-mode UI/brand sweep (2026-06-27, Codex-owned emulator `emulator-5558`):** current debug APK installed, dark mode forced, fresh real paired users created through invite flow (`Codex Dark` + `River Dark`). Swept profile, invite, paired Home, Play, This-or-That, Settings, Notifications, Paywall, Messages, Today. **0 app FATAL/ANR/force-finish in logcat.** Findings added below: C-DARK-UI-001 (P2), C-DARK-UI-002 (P3), C-DARK-UI-003 (P3). Screenshots captured at `/tmp/closer-dark-04-after-permission.png` through `/tmp/closer-dark-25-today.png`.
`R12 (2026-06-27) FRESH FULL ClaudeQAPlan run STARTED (user: "start from the start") | Baseline verified clean: QA free, Sam free (premium revoked), 0 active sessions; build HEAD 2cd0af6 + 3 uncommitted art files installed both emulators (5554=Dark, 5556=Light) | A ✅ (A-201 P1 Date-Match premium bypass) | B ✅ (4 async games full 2-device end-to-end + first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; CC/MemoryLane/DateMatch render-checked; MemoryLane title/preview run-on→Future) | C ✅ (regression-clean vs R10 sweep; Messages/Today/Subscription + organic A/B + R11 decoupled art; NEW C-ART-EDGE-002 P3 direct-call hero hard edges on dark) | D ✅ (LIVE: D1 game answers enc:v1: · D3 non-member 403×4 + member-scoped · D5 self-grant→403; D2/D4/D6/D7 carried R7/R10, rules unchanged) — cornerstone clean | E ✅ (smoke 6/6 + Pass B triggers) | F ✅ (concurrency+process-death+offline) | G ✅ (security half live) | H (finding C-ART-EDGE-002) | I ✅ (jank 4.10%) | J (J-OBS P3) | **FIX PHASE ✅ — A-201 (P1) FIXED+VERIFIED LIVE** (Date Match LOVE/MAYBE on premium idea → Paywall via CouplePremiumChecker; SKIP passes; 0 FATAL). C-DARKART-001+C-ART-EDGE-001 held → PRUNED. | **R12 COMPLETE — FLAWLESS: 0 open P0P2.** Remaining: 2 non-blocking P3s (C-ART-EDGE-002 hero edges, J-OBS touch targets). | NEXT (R13): confirm A-201 holds → prune; optional P3 polish. Uncommitted (user commits): R11 art files + `ui/dates/DateMatchViewModel.kt` + `ui/dates/DateMatchScreen.kt` (A-201). Carryover from R11 (still valid): C-DARKART-001 (P2) + C-ART-EDGE-001 (P3) fixed pending 1 confirm; J-OBS (P3) open touch targets.` `R12 (2026-06-27) FRESH FULL ClaudeQAPlan run STARTED (user: "start from the start") | Baseline verified clean: QA free, Sam free (premium revoked), 0 active sessions; build HEAD 2cd0af6 + 3 uncommitted art files installed both emulators (5554=Dark, 5556=Light) | A ✅ (A-201 P1 Date-Match premium bypass) | B ✅ (4 async games full 2-device end-to-end + first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; CC/MemoryLane/DateMatch render-checked; MemoryLane title/preview run-on→Future) | C ✅ (regression-clean vs R10 sweep; Messages/Today/Subscription + organic A/B + R11 decoupled art; NEW C-ART-EDGE-002 P3 direct-call hero hard edges on dark) | D ✅ (LIVE: D1 game answers enc:v1: · D3 non-member 403×4 + member-scoped · D5 self-grant→403; D2/D4/D6/D7 carried R7/R10, rules unchanged) — cornerstone clean | E ✅ (smoke 6/6 + Pass B triggers) | F ✅ (concurrency+process-death+offline) | G ✅ (security half live) | H (finding C-ART-EDGE-002) | I ✅ (jank 4.10%) | J (J-OBS P3) | **FIX PHASE ✅ — A-201 (P1) FIXED+VERIFIED LIVE** (Date Match LOVE/MAYBE on premium idea → Paywall via CouplePremiumChecker; SKIP passes; 0 FATAL). C-DARKART-001+C-ART-EDGE-001 held → PRUNED. | **R12 COMPLETE — FLAWLESS: 0 open P0P2.** Remaining: 2 non-blocking P3s (C-ART-EDGE-002 hero edges, J-OBS touch targets). | NEXT (R13): confirm A-201 holds → prune; optional P3 polish. Uncommitted (user commits): R11 art files + `ui/dates/DateMatchViewModel.kt` + `ui/dates/DateMatchScreen.kt` (A-201). Carryover from R11 (still valid): C-DARKART-001 (P2) + C-ART-EDGE-001 (P3) fixed pending 1 confirm; J-OBS (P3) open touch targets.`
- **(prior) R11 (2026-06-27) confirmation round COMPLETE — FLAWLESS | Fixed last open P2 C-DARKART-001 (in-app-theme art) + P3 C-ART-EDGE-001 (feathered edges) in shared BrandIllustration/EmptyState; verified live both decoupled theme directions, 0 FATAL | 5 R10 P2 fixes re-confirmed + PRUNED (C-HOME-001/C-NAV-002/C-NAV-003/C-PW-001/C-SEC-001) | entrypoint smoke green on fresh APK | 0 open P0P2 | Baseline: both FREE, 0 active sessions; build (HEAD 2cd0af6 + 3 uncommitted art files) installed both emulators | NEXT (R12 = next session): confirm C-DARKART-001 + C-ART-EDGE-001 hold → prune; optional J-OBS (P3) touch targets; then declare program-complete for Android. Art fixes in working tree (user commits).` - **(prior) R11 (2026-06-27) confirmation round COMPLETE — FLAWLESS | Fixed last open P2 C-DARKART-001 (in-app-theme art) + P3 C-ART-EDGE-001 (feathered edges) in shared BrandIllustration/EmptyState; verified live both decoupled theme directions, 0 FATAL | 5 R10 P2 fixes re-confirmed + PRUNED (C-HOME-001/C-NAV-002/C-NAV-003/C-PW-001/C-SEC-001) | entrypoint smoke green on fresh APK | 0 open P0P2 | Baseline: both FREE, 0 active sessions; build (HEAD 2cd0af6 + 3 uncommitted art files) installed both emulators | NEXT (R12 = next session): confirm C-DARKART-001 + C-ART-EDGE-001 hold → prune; optional J-OBS (P3) touch targets; then declare program-complete for Android. Art fixes in working tree (user commits).`
- **Pass B progress (R12):** **1. This or That ✅** — full end-to-end 2-device, NEW style **Light×5 Quick** (R10 was Deep×10): QA started → answered 5 (alt A/B) → first-finisher state; **first-finisher nudge fired** (`partFinishNotifiedAt` set + Sam queue `partner_completed_part` "QA finished their part — your turn to play!"); Sam **joined via Play-hub active state** (at Q1/5, no dup session) → answered all-A → session→completed (0 active); **`partner_finished_game` to BOTH**; reveal **3/5 in sync** symmetric + correct Match/Differ + You/QA attribution on **both** devices (QA dark / Sam light). 0 FATAL. **2. Spin the Wheel ✅****Ready=Start session** (R11 change) verified; spun→Stress→10Q; **mixed answer types** (free-text + 15 scale) render+accept; Sam **joined active session** via Play hub (Q1, no dup/new spin); both finished→completed (0 active); results "Here's how you each answered" with You/QA free-text + scale; **C-NAV-002 RE-VERIFIED LIVE** — results → BACK → wheel hub (Spin/History), NOT the finished session (the R11-deferred confirm). 0 FATAL. **3. How Well ✅** — QA subject 5·Quick (answered 5 about self), Sam **joined as guesser** ("Predict how QA answered…", asymmetric), guessed 5 → score **5/5 "Perfect read"** + per-Q breakdown (✓ + answer, choice+scale) symmetric both devices; completed (0 active). **4. Desire Sync ✅** (premium, couple-shared via Sam-on) — free QA opened setup (no paywall); QA all-Yes, Sam joined + Y,Y,Y,N,N → reveal **"3 shared desires · 2 kept private"** (only mutual-Yes shown, 2 mismatches "Private") symmetric both devices; completed (0 active); Sam premium restored OFF. **All 4 async session games verified end-to-end.** - **Pass B progress (R12):** **1. This or That ✅** — full end-to-end 2-device, NEW style **Light×5 Quick** (R10 was Deep×10): QA started → answered 5 (alt A/B) → first-finisher state; **first-finisher nudge fired** (`partFinishNotifiedAt` set + Sam queue `partner_completed_part` "QA finished their part — your turn to play!"); Sam **joined via Play-hub active state** (at Q1/5, no dup session) → answered all-A → session→completed (0 active); **`partner_finished_game` to BOTH**; reveal **3/5 in sync** symmetric + correct Match/Differ + You/QA attribution on **both** devices (QA dark / Sam light). 0 FATAL. **2. Spin the Wheel ✅****Ready=Start session** (R11 change) verified; spun→Stress→10Q; **mixed answer types** (free-text + 15 scale) render+accept; Sam **joined active session** via Play hub (Q1, no dup/new spin); both finished→completed (0 active); results "Here's how you each answered" with You/QA free-text + scale; **C-NAV-002 RE-VERIFIED LIVE** — results → BACK → wheel hub (Spin/History), NOT the finished session (the R11-deferred confirm). 0 FATAL. **3. How Well ✅** — QA subject 5·Quick (answered 5 about self), Sam **joined as guesser** ("Predict how QA answered…", asymmetric), guessed 5 → score **5/5 "Perfect read"** + per-Q breakdown (✓ + answer, choice+scale) symmetric both devices; completed (0 active). **4. Desire Sync ✅** (premium, couple-shared via Sam-on) — free QA opened setup (no paywall); QA all-Yes, Sam joined + Y,Y,Y,N,N → reveal **"3 shared desires · 2 kept private"** (only mutual-Yes shown, 2 mismatches "Private") symmetric both devices; completed (0 active); Sam premium restored OFF. **All 4 async session games verified end-to-end.**
@ -35,8 +38,17 @@
|---|---|---| |---|---|---|
| P0 | 0 | 0 | | P0 | 0 | 0 |
| P1 | 0 | **1** (A-201) | | P1 | 0 | **1** (A-201) |
| P2 | 0 | 0 | | P2 | **1** (C-DARK-UI-001) | 0 |
| P3 | **2** (J-OBS, C-ART-EDGE-002) | 0 | | P3 | **4** (J-OBS, C-ART-EDGE-002, C-DARK-UI-002, C-DARK-UI-003) | 0 |
## Issues — ad hoc dark-mode UI/brand review (2026-06-27)
> Dedicated emulator: `CloserCodexQA` / `emulator-5558`; fresh paired users created through the real invite flow; system dark mode forced. This pass focused on brand fit, image integration, dark-mode button/text visibility, route integrity, and logs after navigation/game launch. **Clean surfaces:** profile creation, invite, paired Home, Settings ("Connected with Codex Dark"), Notifications, Messages, and Paywall primary content/buttons are readable and on-brand in dark mode. **Known C-ART-EDGE-002 reproduced:** Today's direct-call hero art still appears as a bright pasted light tile on dark, already tracked below.
| ID | Sev | Area | Description | Evidence / repro | Suggested fix | Status |
|---|---|---|---|---|---|---|
| C-DARK-UI-001 | P2 | This or That / dark active gameplay | **Dark-mode gameplay still looks off-brand and weakly legible.** The diagonal line + two-circle backdrop cuts through the question and reads like a forgotten placeholder, especially in dark mode. A/B option labels and option text are too dim on the dark cards; mood-picker duration chips are also low-contrast. This is the one screen where dark button/choice text does not meet the rest of the app's standard. | `emulator-5558`: Play → This or That → choose mood/count → active question. Screenshot: `/tmp/closer-dark-20-this-or-that-game.png`; setup chip contrast: `/tmp/closer-dark-19-this-or-that-mood.png`. Logcat after launch: 0 app FATAL/ANR/force-finish. | Replace the placeholder-like line/circle motif with branded game art or a subtle surface treatment; use theme tokens with high-contrast option text for dark; verify A/B cards, selected/pressed/disabled states, and all mood/count chips in both themes. | **Open** |
| C-DARK-UI-002 | P3 | Paired Home / first-launch check-in modal | **Slider label and value collide in dark mode.** The second check-in row reads visually like `communicate?5` because the label runs into the numeric value. Button text ("Submit", "Skip for now") is readable, but the modal needs spacing/layout polish. | `emulator-5558`: first paired Home launch opened the quick check-in modal. Screenshot: `/tmp/closer-dark-16-paired-home.png`. | Give slider rows a stable two-column layout or wrap/value-align the score independently; verify at default and larger font scales. | **Open** |
| C-DARK-UI-003 | P3 | Insets / bottom navigation and gesture area | **Several dark-mode scroll surfaces render useful content too close to or under bottom nav/gesture areas.** Play clips the lower "Desire Sync" card behind bottom nav; unpaired Home's bottom CTA is too tight to the nav area; Paywall's "Already subscribed? / Restore" card starts under the gesture area. Text is mostly readable, but it looks unfinished and can hide tap targets. | Screenshots: `/tmp/closer-dark-18-play.png`, `/tmp/closer-dark-07-post-profile.png`, `/tmp/closer-dark-23-paywall.png`. | Audit scaffold/content padding for bottom nav + system gesture insets; add consistent `navigationBarsPadding`/bottom spacer to scrollable content and modal/fullscreen paywall surfaces. | **Open** |
## Issues — R12 (0 open P0P2 = FLAWLESS · A-201 P1 fixed+verified-live pending 1 confirm · 2 open P3 [J-OBS, C-ART-EDGE-002]) ## Issues — R12 (0 open P0P2 = FLAWLESS · A-201 P1 fixed+verified-live pending 1 confirm · 2 open P3 [J-OBS, C-ART-EDGE-002])
> R12 was a FRESH FULL AJ run. Found A-201 (P1, Date Match premium bypass) — **fixed + verified live this round** (gated > R12 was a FRESH FULL AJ run. Found A-201 (P1, Date Match premium bypass) — **fixed + verified live this round** (gated

View File

@ -7,35 +7,18 @@ 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).
- **Memory Lane capsule list: separate title from body preview.** (R12 Pass B) In the capsule list each row runs - **✅ DONE — Consistent brand glyphs across game cards + waiting surfaces.** G-set + G2 (17 glyphs) in
the title straight into the body preview with no separator/space/style break (e.g. "QA Memory Test" + "Remember this `res/drawable-nodpi/glyph_*.xml`; **13 wired + verified live:** every Play-hub card (This or That, How Well, Desire
QA round…" renders as one run-on string). Give the preview its own line / muted style, or a separator, so title and Sync, Connection Challenges, Memory Lane, Date Match, Plan Date, Question Packs, Bucket List, Past Games — Spin the
body read distinctly. (Partly confounded by R12 adb-typed test titles — re-check with clean data; cosmetic only.) Wheel keeps its full illustration), WaitingForPartner per-game glyph, and Settings (Subscription/Security/Privacy/
Delete). 4 unused have no clean slot (notif uses `ic_notification_closer`; Today uses hero art; quiet-hours uses its
illustration; no export-data row exists). Full map in `ClaudeBrandingReview.md`. _(This-or-That backdrop redesign is
Codex C-DARK-UI-001.)_
- **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.
- **Notify the free partner when the couple gains premium.** When one partner subscribes, the other's app unlocks
(couple-shared premium) but they get **no notification** — they only find out next time they open a gated feature. A
`subscription_entitlement_changed` push ("You both have Premium now ✨") would close the loop. *Prompted by:* Pass E
(R8): the type isn't implemented; couple-shared unlock is silent for the non-subscriber.
- **Minor proactive-notification gaps (low priority).** No push when a partner *joins* your active game - **Minor proactive-notification gaps (low priority).** No push when a partner *joins* your active game
(`partner_joined_game`) or *ends/abandons* one (`game_ended`/`game_abandoned`) — the other partner sees it (`partner_joined_game`) or *ends/abandons* one (`game_ended`/`game_abandoned`) — the other partner sees it
in-session / on WaitingForPartner, so nothing's broken, just less proactive. *Prompted by:* Pass E (R8) inventory — in-session / on WaitingForPartner, so nothing's broken, just less proactive. *Prompted by:* Pass E (R8) inventory —
these speculative types aren't implemented. these speculative types aren't implemented.
- **Retry the daily-reveal `isRevealed` write so the "partner opened" push is never silently lost.**
`performCoupleKeyReveal` sets local Room `markRevealed` *then* the Firestore `markRevealed` (best-effort,
`runCatching`). If that Firestore write fails (offline/transient), Room is already revealed → re-opening the reveal
takes `load()`'s auto-decrypt branch (which only runs when local `isRevealed==true`) and **never retries** the
Firestore write, so `onAnswerRevealed` never fires and the partner never gets the "opened your answers" push. Normal
online flow is fine (verified live). Fix: on reveal-screen load, if local says revealed but the server doc's
`isRevealed` is still false, re-issue `markRevealed`. *Prompted by:* daily-reveal QA (2026-06-26) — low severity
(content reveal unaffected; only the courtesy push on a write-failure edge).
- **Clarify Connection Challenges day-progress when partners are out of step.** If one partner catches up a *missed* day ("Pick it back up") while the other doesn't, the two devices show different **"Day N of 7"** (seen R10: QA Day 4 vs Sam Day 3) even though the 🔥 streak stays in sync on both. Not broken (plausibly individual-pace-through-the-series by design), but two people in the same shared challenge seeing different day numbers is confusing — consider a shared "you're on Day N together" framing or a clearer caught-up/ahead indicator. *Prompted by:* Pass B (R10) Connection Challenges playthrough. - **Clarify Connection Challenges day-progress when partners are out of step.** If one partner catches up a *missed* day ("Pick it back up") while the other doesn't, the two devices show different **"Day N of 7"** (seen R10: QA Day 4 vs Sam Day 3) even though the 🔥 streak stays in sync on both. Not broken (plausibly individual-pace-through-the-series by design), but two people in the same shared challenge seeing different day numbers is confusing — consider a shared "you're on Day N together" framing or a clearer caught-up/ahead indicator. *Prompted by:* Pass B (R10) Connection Challenges playthrough.
### Security hardening (defense-in-depth — not vulnerabilities; rules already hold) ### Security hardening (defense-in-depth — not vulnerabilities; rules already hold)
@ -44,14 +27,28 @@ Improvement & feature ideas surfaced while QA-testing as a consumer (each works
Check token** (raw Firestore REST) returned `200` for a member — so rules are the *sole* gate. Rules correctly deny Check token** (raw Firestore REST) returned `200` for a member — so rules are the *sole* gate. Rules correctly deny
non-members/cross-couple (all `403`), so this is not a live hole, but enabling App Check enforcement on Firestore non-members/cross-couple (all `403`), so this is not a live hole, but enabling App Check enforcement on Firestore
would block non-app clients entirely (defense-in-depth). *Prompted by:* R7 D3 raw-API angle. would block non-app clients entirely (defense-in-depth). *Prompted by:* R7 D3 raw-API angle.
- **Tighten the `users/{uid}` update rule to a field allowlist.** The rule only blocks changing `hasPremium`; a user
can write arbitrary *other* fields to their own doc (e.g. a cosmetic `plan`/junk). No gate reads those (premium gates
on the server-only `users/{uid}/entitlements/premium` subcollection + `category.access`), so it grants nothing — but
restricting updates to a known field set is cleaner. *Prompted by:* R7 D3 (`plan` field writable, unused by gating).
> 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-27, Future.md backend pass — deployed + verified live):
- subscription_entitlement_changed push — new Cloud Function `onEntitlementChanged` (users/{uid}/entitlements/premium
onWrite, edge-triggered inactive→active) notifies the PARTNER "X upgraded — you both have Premium now"; skips if the
partner already had premium. Client type `SUBSCRIPTION_CHANGED` added (routes to Subscription). Verified: QA premium
ON → Sam queued the push; Sam ON while QA already premium → no redundant notify. Deployed to closer-app-22014.
- users/{uid} update-rule field allowlist — `allow update` now uses `affectedKeys().hasOnly([...12 model/aux fields])`,
blocking junk keys + `hasPremium`. Verified raw-API: allowlisted field PATCH 200, junk field 403, hasPremium 403.
(Keep the list synced with User.kt + FirestoreUserDataSource — guard noted in firestore.rules.) Deployed.
Completed (2026-06-27, Future.md fix pass):
- Daily-reveal isRevealed retry — on reveal load, if local says revealed but the server doc's isRevealed is still
false (a prior best-effort markRevealed failed offline/transiently), re-issue markRevealed idempotently so
onAnswerRevealed fires and the partner gets the "opened your answers" push (AnswerRevealViewModel.load()).
- Brand glyphs — G-set copied to res/drawable/; Date Match + Plan Date cards wired to glyph_date_card_heart
(remaining surfaces tracked above + in ClaudeBrandingReview.md).
- Memory Lane "title runs into preview" — investigated, NOT a bug: the capsule list renders title (own line, ellipsized)
+ open-date; sealed content is never previewed. The R12 "run-on" was adb-typed test data landing in the title field.
Completed (2026-06-25) and removed from the backlog: 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 - 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). (Desire Sync is already gender-neutral, so no tailoring fallback was needed).

View File

@ -279,6 +279,14 @@ enum class PartnerNotificationType(
body = "Tap to create a new invite.", body = "Tap to create a new invite.",
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
),
// Couple-shared premium: the OTHER partner subscribed, so this user's app just unlocked.
// Closes the silent-unlock gap (the non-subscriber otherwise found out only on a gated tap).
SUBSCRIPTION_CHANGED(
title = "Premium unlocked ✨",
body = "You both have Premium now.",
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
); );
/** /**
@ -313,6 +321,7 @@ enum class PartnerNotificationType(
DATE_MATCH -> AppRoute.DATE_MATCHES DATE_MATCH -> AppRoute.DATE_MATCHES
REENGAGEMENT -> AppRoute.DAILY_QUESTION REENGAGEMENT -> AppRoute.DAILY_QUESTION
PARTNER_UNPAIRED -> AppRoute.HOME PARTNER_UNPAIRED -> AppRoute.HOME
SUBSCRIPTION_CHANGED -> AppRoute.SUBSCRIPTION
} }
companion object { companion object {
@ -338,6 +347,7 @@ enum class PartnerNotificationType(
"date_match" -> DATE_MATCH "date_match" -> DATE_MATCH
"reengagement" -> REENGAGEMENT "reengagement" -> REENGAGEMENT
"partner_left", "partner_deleted_account" -> PARTNER_UNPAIRED "partner_left", "partner_deleted_account" -> PARTNER_UNPAIRED
"subscription_entitlement_changed" -> SUBSCRIPTION_CHANGED
// NB: "invite_created" is a server-side audit-log entry (read:true, never sent // NB: "invite_created" is a server-side audit-log entry (read:true, never sent
// as a push) and "spki" is a crypto key-format string in the RevenueCat webhook // as a push) and "spki" is a crypto key-format string in the RevenueCat webhook
// (not a notification type at all) — neither needs client routing. E-002. // (not a notification type at all) — neither needs client routing. E-002.

View File

@ -148,6 +148,20 @@ class AnswerRevealViewModel @Inject constructor(
sealedRevealPhase = SealedRevealPhase.REVEALED sealedRevealPhase = SealedRevealPhase.REVEALED
) )
} }
// Retry the server `isRevealed` write if a prior best-effort `markRevealed` failed
// (offline/transient). Local says revealed but the server doc may still be false, so
// `onAnswerRevealed` never fired and the partner never got the "opened your answers"
// push. Re-issue idempotently (the function fires only on the false→true transition).
val uid = authRepository.currentUserId
if (uid != null) {
runCatching {
val ownServer = firestoreAnswerDataSource.getAnswerForUser(coupleId, uid, effectiveDate(answer))
if (ownServer != null && !ownServer.isRevealed) {
firestoreAnswerDataSource.markRevealed(coupleId, effectiveDate(answer), uid)
}
}.onFailure { crashReporter.recordException(it) }
}
} }
// Live-watch the partner's answer doc so the reveal completes on this device // Live-watch the partner's answer doc so the reveal completes on this device

View File

@ -11,11 +11,17 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import app.closer.ui.components.CloserHeartLoader import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import app.closer.R
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -154,11 +160,20 @@ fun WaitingForPartnerScreen(
) )
} }
else -> { else -> {
CategoryGlyph( Surface(
categoryId = gameTypeGlyphKey(state.gameType), shape = RoundedCornerShape(24.dp),
size = 80.dp, color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.66f),
iconSize = 38.dp modifier = Modifier.size(80.dp)
) ) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = ImageVector.vectorResource(gameTypeGlyphRes(state.gameType)),
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(40.dp)
)
}
}
Text( Text(
text = "Waiting for ${state.partnerName}", text = "Waiting for ${state.partnerName}",
@ -235,10 +250,10 @@ private fun gameTypeRoute(gameType: String): String? = when (gameType) {
else -> null else -> null
} }
private fun gameTypeGlyphKey(gameType: String): String = when (gameType) { private fun gameTypeGlyphRes(gameType: String): Int = when (gameType) {
GameType.WHEEL -> "play" GameType.WHEEL -> R.drawable.glyph_spin_wheel
GameType.THIS_OR_THAT -> "question" GameType.THIS_OR_THAT -> R.drawable.glyph_paired_cards
GameType.HOW_WELL -> "predict" GameType.HOW_WELL -> R.drawable.glyph_how_well
GameType.DESIRE_SYNC -> "sex_and_desire" GameType.DESIRE_SYNC -> R.drawable.glyph_sealed_answer
else -> "play" else -> R.drawable.glyph_paired_cards
} }

View File

@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -151,7 +152,7 @@ private fun PlayHubContent(
CompactPlayCard( CompactPlayCard(
title = "Question Packs", title = "Question Packs",
subtitle = "Themed prompts to explore together", subtitle = "Themed prompts to explore together",
icon = Icons.Filled.Star, icon = ImageVector.vectorResource(R.drawable.glyph_question_packs),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = { onNavigate(AppRoute.QUESTION_PACKS) } onClick = { onNavigate(AppRoute.QUESTION_PACKS) }
@ -166,7 +167,7 @@ private fun PlayHubContent(
CompactPlayCard( CompactPlayCard(
title = "Date Match", title = "Date Match",
subtitle = "Swipe ideas", subtitle = "Swipe ideas",
icon = Icons.Filled.Favorite, icon = ImageVector.vectorResource(R.drawable.glyph_date_card_heart),
tint = MaterialTheme.colorScheme.secondary, tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onPlay(AppRoute.DATE_MATCH) } onClick = { onPlay(AppRoute.DATE_MATCH) }
@ -174,7 +175,7 @@ private fun PlayHubContent(
CompactPlayCard( CompactPlayCard(
title = "Plan Date", title = "Plan Date",
subtitle = "Set the shape", subtitle = "Set the shape",
icon = Icons.Filled.Star, icon = ImageVector.vectorResource(R.drawable.glyph_date_card_heart),
tint = MaterialTheme.colorScheme.tertiary, tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onPlay(AppRoute.DATE_BUILDER) } onClick = { onPlay(AppRoute.DATE_BUILDER) }
@ -190,7 +191,7 @@ private fun PlayHubContent(
CompactPlayCard( CompactPlayCard(
title = "Bucket List", title = "Bucket List",
subtitle = "Save ideas", subtitle = "Save ideas",
icon = Icons.Filled.Done, icon = ImageVector.vectorResource(R.drawable.glyph_bucket_list),
tint = MaterialTheme.colorScheme.tertiary, tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onPlay(AppRoute.BUCKET_LIST) } onClick = { onPlay(AppRoute.BUCKET_LIST) }
@ -198,7 +199,7 @@ private fun PlayHubContent(
CompactPlayCard( CompactPlayCard(
title = "Past Games", title = "Past Games",
subtitle = "All results", subtitle = "All results",
icon = Icons.Filled.Home, icon = ImageVector.vectorResource(R.drawable.glyph_past_games),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
locked = !hasPremium, locked = !hasPremium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@ -236,10 +237,11 @@ private fun ThisOrThatCard(
modifier = Modifier.size(52.dp) modifier = Modifier.size(52.dp)
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Text( Icon(
text = "A/B", imageVector = ImageVector.vectorResource(R.drawable.glyph_paired_cards),
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), contentDescription = null,
color = MaterialTheme.colorScheme.onSecondaryContainer tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(26.dp)
) )
} }
} }
@ -302,12 +304,20 @@ private fun DesireSyncCard(
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
CategoryGlyph( Surface(
categoryId = "sex_and_desire", shape = RoundedCornerShape(CloserRadii.Tile),
modifier = Modifier.size(52.dp), color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.78f),
size = 52.dp, modifier = Modifier.size(52.dp)
iconSize = 25.dp ) {
) Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.glyph_sealed_answer),
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(26.dp)
)
}
}
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
@ -388,12 +398,20 @@ private fun HowWellCard(
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
CategoryGlyph( Surface(
categoryId = "predict", shape = RoundedCornerShape(CloserRadii.Tile),
modifier = Modifier.size(52.dp), color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.78f),
size = 52.dp, modifier = Modifier.size(52.dp)
iconSize = 25.dp ) {
) Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.glyph_how_well),
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(26.dp)
)
}
}
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
@ -461,9 +479,11 @@ private fun ConnectionChallengesCard(
modifier = Modifier.size(52.dp) modifier = Modifier.size(52.dp)
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Text( Icon(
text = "🔗", imageVector = ImageVector.vectorResource(R.drawable.glyph_connection_challenge),
style = MaterialTheme.typography.headlineSmall contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(26.dp)
) )
} }
} }
@ -534,9 +554,11 @@ private fun MemoryLaneCard(
modifier = Modifier.size(52.dp) modifier = Modifier.size(52.dp)
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Text( Icon(
text = "📦", imageVector = ImageVector.vectorResource(R.drawable.glyph_memory_capsule),
style = MaterialTheme.typography.headlineSmall contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(26.dp)
) )
} }
} }

View File

@ -37,6 +37,8 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import app.closer.R
import androidx.compose.ui.res.vectorResource
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.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@ -515,7 +517,7 @@ fun SettingsScreen(
SettingsSection(title = "Premium", accent = Color(0xFFE7A2D1)) { SettingsSection(title = "Premium", accent = Color(0xFFE7A2D1)) {
SettingsRow( SettingsRow(
icon = Icons.Filled.Favorite, icon = ImageVector.vectorResource(R.drawable.glyph_couple_premium),
label = "Subscription", label = "Subscription",
subtitle = "Manage one plan for both partners", subtitle = "Manage one plan for both partners",
onClick = { onNavigate(AppRoute.SUBSCRIPTION) }, onClick = { onNavigate(AppRoute.SUBSCRIPTION) },
@ -525,14 +527,14 @@ fun SettingsScreen(
SettingsSection(title = "Privacy and safety", accent = Color(0xFFD9B8FF)) { SettingsSection(title = "Privacy and safety", accent = Color(0xFFD9B8FF)) {
SettingsRow( SettingsRow(
icon = Icons.Filled.Lock, icon = ImageVector.vectorResource(R.drawable.glyph_privacy_lock),
label = "Security", label = "Security",
subtitle = "Recovery phrase and account protection", subtitle = "Recovery phrase and account protection",
onClick = { onNavigate(AppRoute.SECURITY) } onClick = { onNavigate(AppRoute.SECURITY) }
) )
SettingsSectionDivider() SettingsSectionDivider()
SettingsRow( SettingsRow(
icon = Icons.Filled.Lock, icon = ImageVector.vectorResource(R.drawable.glyph_privacy_lock),
label = "Privacy & Terms", label = "Privacy & Terms",
subtitle = "Your data, policies, and legal details", subtitle = "Your data, policies, and legal details",
onClick = { onNavigate(AppRoute.PRIVACY) } onClick = { onNavigate(AppRoute.PRIVACY) }
@ -541,7 +543,7 @@ fun SettingsScreen(
SettingsSection(title = "Account", accent = Color(0xFFFFD9E8)) { SettingsSection(title = "Account", accent = Color(0xFFFFD9E8)) {
SettingsRow( SettingsRow(
icon = Icons.Filled.Warning, icon = ImageVector.vectorResource(R.drawable.glyph_delete_account),
label = "Delete account", label = "Delete account",
subtitle = "Permanently remove your Closer account", subtitle = "Permanently remove your Closer account",
onClick = { onNavigate(AppRoute.DELETE_ACCOUNT) }, onClick = { onNavigate(AppRoute.DELETE_ACCOUNT) },

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M6,4.4h12c1.1,0 2,0.9 2,2v11.2c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V6.4c0,-1.1 0.9,-2 2,-2zM8.55,8.35L7.45,7.25L6.35,8.35l2.2,2.2l3.45,-3.45l-1.1,-1.1L8.55,8.35zM13.1,8.1h4.3V6.6h-4.3v1.5zM8.55,14.55l-1.1,-1.1l-1.1,1.1l2.2,2.2l3.45,-3.45l-1.1,-1.1l-2.35,2.35zM13.1,14.3h2.6v-1.5h-2.6v1.5zM17.25,11.2l0.5,1.38l1.38,0.5l-1.38,0.5l-0.5,1.38l-0.5,-1.38l-1.38,-0.5l1.38,-0.5l0.5,-1.38z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M12,3a9,9 0,1 0,6.36 15.36l-2.12,-2.12A6,6 0,1 1,12 6h2.4V3H12zM12.01,9.2c-1.32,-1.42 -3.76,-0.5 -3.76,1.5c0,1.75 1.9,3.15 3.76,4.75c1.86,-1.6 3.74,-3 3.74,-4.75c0,-2 -2.42,-2.92 -3.74,-1.5zM12,16.4a1.15,1.15 0,0 0,-0.45 2.2V20h0.9v-1.4a1.15,1.15 0,0 0,-0.45 -2.2z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M5.6,14.6a2.6,2.6 0,1 0,0 -5.2a2.6,2.6 0,0 0,0 5.2zM10.2,11.7c0.15,-1.35 1.3,-2.4 2.7,-2.4c1.1,0 2.05,0.65 2.48,1.6c-0.47,0.5 -0.78,1.15 -0.85,1.88a2.72,2.72 0,0 1,-4.33 -1.08zM12.9,14.75a2.6,2.6 0,1 0,0 -5.2a2.6,2.6 0,0 0,0 5.2zM20.7,11.1l0.58,1.62l1.62,0.58l-1.62,0.58l-0.58,1.62l-0.58,-1.62l-1.62,-0.58l1.62,-0.58l0.58,-1.62zM18.55,17.2a2.6,2.6 0,1 0,0 -5.2a2.6,2.6 0,0 0,0 5.2zM7.85,12.6c0.1,-0.52 0.1,-1.04 0,-1.55l2.05,-0.32c-0.07,0.57 -0.02,1.15 0.15,1.68L7.85,12.6zM15.1,12.6c0.05,-0.55 0.25,-1.07 0.56,-1.52l0.78,0.9c-0.18,0.36 -0.28,0.76 -0.28,1.18c0,0.18 0.02,0.35 0.05,0.52l-1.26,-0.25c0.07,-0.27 0.12,-0.54 0.15,-0.83z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M8.3,7.6c-1.04,-1.12 -2.95,-0.4 -2.95,1.18c0,1.38 1.5,2.48 2.95,3.72c1.46,-1.24 2.95,-2.34 2.95,-3.72c0,-1.58 -1.9,-2.3 -2.95,-1.18zM15.7,7.6c-1.04,-1.12 -2.95,-0.4 -2.95,1.18c0,1.38 1.5,2.48 2.95,3.72c1.46,-1.24 2.95,-2.34 2.95,-3.72c0,-1.58 -1.9,-2.3 -2.95,-1.18zM6.2,15.3h11.6l1.2,4.2H5l1.2,-4.2zM7.3,8.45L9.2,3.9l2.1,4.35h1.4l2.1,-4.35l1.9,4.55L15.9,9l-1.05,-1.7l-1.25,2.55h-3.2L9.15,7.3L8.1,9l-0.8,-0.55z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M6,4h12c1.1,0 2,0.9 2,2v12c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V6c0,-1.1 0.9,-2 2,-2zM6,8h12V6H6v2zM8.6,12.7c0,-1.55 1.88,-2.25 2.9,-1.16c0.18,0.2 0.5,0.2 0.68,0c1.02,-1.1 2.9,-0.39 2.9,1.16c0,1.36 -1.47,2.45 -3.24,3.88c-1.77,-1.43 -3.24,-2.52 -3.24,-3.88z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M6,3.8h12c1.1,0 2,0.9 2,2v12.4c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V5.8c0,-1.1 0.9,-2 2,-2zM6,8.2h12V5.8H6v2.4zM8.3,11.4h2v2h-2v-2zM14,11.82c-0.9,-0.96 -2.55,-0.34 -2.55,1.02c0,1.19 1.28,2.14 2.55,3.22c1.26,-1.08 2.54,-2.03 2.54,-3.22c0,-1.36 -1.64,-1.98 -2.54,-1.02zM8.3,15.5h2v2h-2v-2z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M8,4.5h8l0.8,1.5H20v2H4V6h3.2L8,4.5zM6.4,9.5h11.2l-0.8,9.3c-0.1,1 -0.95,1.7 -1.95,1.7h-5.7c-1,0 -1.85,-0.7 -1.95,-1.7L6.4,9.5zM9.5,11.7l0.4,6h1.6l-0.35,-6H9.5zM12.5,11.7l-0.35,6h1.6l0.4,-6H12.5z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M6,3.8h8.5L20,9.3v8.9c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V5.8c0,-1.1 0.9,-2 2,-2zM14,5.7v4.1h4.1L14,5.7zM11,11h2v4.15l1.55,-1.55L16,15.05l-4,4l-4,-4l1.45,-1.45L11,15.15V11z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M5.8,5.4h5.1c1.05,0 1.9,0.85 1.9,1.9v3.25c0,1.05 -0.85,1.9 -1.9,1.9H8.9l-2.05,2.05c-0.33,0.33 -0.9,0.1 -0.9,-0.38v-1.67H5.8c-1.05,0 -1.9,-0.85 -1.9,-1.9V7.3c0,-1.05 0.85,-1.9 1.9,-1.9zM13.1,9.35h5.1c1.05,0 1.9,0.85 1.9,1.9v3.25c0,1.05 -0.85,1.9 -1.9,1.9h-0.15v1.67c0,0.48 -0.57,0.71 -0.9,0.38L15.1,16.4h-2c-1.05,0 -1.9,-0.85 -1.9,-1.9v-0.55c1.75,-0.15 3.1,-1.62 3.1,-3.4V9.42c-0.38,-0.05 -0.78,-0.07 -1.2,-0.07zM12,11.6c-0.68,-0.74 -1.9,-0.25 -1.9,0.76c0,0.9 0.96,1.6 1.9,2.42c0.94,-0.82 1.9,-1.52 1.9,-2.42c0,-1.01 -1.22,-1.5 -1.9,-0.76z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M7,6h10a5,5 0,0 1,0 10H7A5,5 0,0 1,7 6zM7,9a2,2 0,0 0,0 4h10a2,2 0,0 0,0 -4H7zM11.98,10.1c-0.66,-0.72 -1.88,-0.25 -1.88,0.75c0,0.88 0.95,1.58 1.88,2.38c0.93,-0.8 1.87,-1.5 1.87,-2.38c0,-1 -1.21,-1.47 -1.87,-0.75zM4,18h16v2H4v-2z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M5.4,5.2h8.2c1,0 1.8,0.8 1.8,1.8v9.8c0,1 -0.8,1.8 -1.8,1.8H5.4c-1,0 -1.8,-0.8 -1.8,-1.8V7c0,-1 0.8,-1.8 1.8,-1.8zM5.8,8.4l3.7,3.1l3.7,-3.1V7.6H5.8v0.8zM5.8,10.7v5.5h7.4v-5.5l-3.7,3.1l-3.7,-3.1zM12.8,5.4l2.4,-0.7c1,-0.28 2,0.28 2.28,1.25l2.68,9.42c0.28,0.97 -0.28,1.98 -1.25,2.26l-1.9,0.54V7c0,-1.32 -0.82,-2.45 -1.98,-2.9z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M7.2,5.2h9.6v1.9h2.1c0.62,0 1.12,0.5 1.12,1.12c0,2.45 -1.48,4.52 -3.58,5.42c-0.46,1.32 -1.55,2.34 -2.91,2.72v1.44h2.22c0.58,0 1.05,0.47 1.05,1.05V20H7.2v-1.15c0,-0.58 0.47,-1.05 1.05,-1.05h2.22v-1.44c-1.36,-0.38 -2.45,-1.4 -2.91,-2.72c-2.1,-0.9 -3.58,-2.97 -3.58,-5.42c0,-0.62 0.5,-1.12 1.12,-1.12h2.1V5.2zM5.95,9.1c0.14,0.98 0.64,1.84 1.35,2.45V9.1H5.95zM16.7,9.1v2.45c0.71,-0.61 1.21,-1.47 1.35,-2.45H16.7zM10.2,8.55h3.6v1.52h-3.6V8.55zM12,10.25l0.56,1.42l1.52,0.1l-1.18,0.96l0.38,1.48L12,13.38l-1.28,0.83l0.38,-1.48l-1.18,-0.96l1.52,-0.1L12,10.25z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M7.15,10.1V8.55C7.15,5.87 9.32,3.7 12,3.7s4.85,2.17 4.85,4.85v1.55h0.65c1.1,0 2,0.9 2,2v5.6c0,1.1 -0.9,2 -2,2h-11c-1.1,0 -2,-0.9 -2,-2v-5.6c0,-1.1 0.9,-2 2,-2h0.65zM9.35,10.1h5.3V8.55c0,-1.46 -1.19,-2.65 -2.65,-2.65S9.35,7.09 9.35,8.55v1.55zM12,13.1c-0.8,0 -1.45,0.65 -1.45,1.45c0,0.48 0.24,0.91 0.6,1.18v1.42h1.7v-1.42c0.36,-0.27 0.6,-0.7 0.6,-1.18c0,-0.8 -0.65,-1.45 -1.45,-1.45z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M8.2,4.2h8.7c1.1,0 2,0.9 2,2v10.6c0,1.1 -0.9,2 -2,2H8.2c-1.1,0 -2,-0.9 -2,-2V6.2c0,-1.1 0.9,-2 2,-2zM8.25,8.2h8.6V6.5h-8.6v1.7zM12.55,11.3c-0.74,-0.82 -2.14,-0.28 -2.14,0.86c0,1 1.08,1.8 2.14,2.72c1.06,-0.92 2.13,-1.72 2.13,-2.72c0,-1.14 -1.39,-1.68 -2.13,-0.86zM5.1,6.4c-0.76,0.28 -1.3,1.02 -1.3,1.88V17.3c0,1.66 1.34,3 3,3h8.15c0.78,0 1.48,-0.3 2.02,-0.8H7.4c-1.27,0 -2.3,-1.03 -2.3,-2.3V6.4zM2.4,9.15c-0.58,0.36 -0.95,1 -0.95,1.72v6.85c0,2.1 1.7,3.8 3.8,3.8h7.25c0.65,0 1.25,-0.22 1.72,-0.58H6.8c-2.43,0 -4.4,-1.97 -4.4,-4.4V9.15z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M15.4,3.2a8.2,8.2 0,1 0,5.4 12.95A7.2,7.2 0,0 1,11.85 7.2A7.1,7.1 0,0 1,15.4 3.2zM6.6,6.2l0.55,1.12l1.23,0.18l-0.9,0.87l0.22,1.23l-1.1,-0.58l-1.1,0.58l0.22,-1.23l-0.9,-0.87l1.23,-0.18l0.55,-1.12zM18.75,7.35l0.42,0.86l0.95,0.14l-0.69,0.67l0.16,0.94l-0.84,-0.45l-0.85,0.45l0.16,-0.94l-0.68,-0.67l0.94,-0.14l0.43,-0.86z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M5.5,4.5h13c1.1,0 2,0.9 2,2v11c0,1.1 -0.9,2 -2,2h-13c-1.1,0 -2,-0.9 -2,-2v-11c0,-1.1 0.9,-2 2,-2zM5.7,7.8l6.3,4.1l6.3,-4.1V6.7H5.7v1.1zM5.7,10.15v7.15h12.6v-7.15L12,14.26L5.7,10.15zM10.05,15.25a1.95,1.95 0,0 1,3.9 0v0.45h0.3c0.44,0 0.8,0.36 0.8,0.8v1.1H8.95v-1.1c0,-0.44 0.36,-0.8 0.8,-0.8h0.3v-0.45zM11.2,15.7h1.6v-0.45a0.8,0.8 0,0 0,-1.6 0v0.45z"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M12,4.2c4.3,0 7.8,3.5 7.8,7.8s-3.5,7.8 -7.8,7.8S4.2,16.3 4.2,12S7.7,4.2 12,4.2zM12,6.4v4.45l3.15,-3.15A5.55,5.55 0,0 0,12 6.4zM16.3,8.85L13.15,12h4.45a5.55,5.55 0,0 0,-1.3 -3.15zM17.6,13.15h-4.45l3.15,3.15a5.55,5.55 0,0 0,1.3 -3.15zM15.15,17.45L12,14.3v4.45a5.55,5.55 0,0 0,3.15 -1.3zM10.85,18.75V14.3L7.7,17.45a5.55,5.55 0,0 0,3.15 1.3zM6.55,16.3L9.7,13.15H5.25a5.55,5.55 0,0 0,1.3 3.15zM5.25,10.85H9.7L6.55,7.7a5.55,5.55 0,0 0,-1.3 3.15zM7.7,6.55L10.85,9.7V5.25a5.55,5.55 0,0 0,-3.15 1.3zM12,10.25a1.75,1.75 0,1 1,0 3.5a1.75,1.75 0,0 1,0 -3.5zM12,1.8l1.72,2.75h-3.44L12,1.8z"/>
</vector>

View File

@ -189,8 +189,17 @@ service cloud.firestore {
); );
allow create: if isOwner(uid) allow create: if isOwner(uid)
&& !request.resource.data.keys().hasAny(['hasPremium']); && !request.resource.data.keys().hasAny(['hasPremium']);
// Field allowlist (hardening): the owner may update ONLY known profile/aux fields. This blocks
// the server-owned `hasPremium`/`premium` flags AND any arbitrary junk keys a client could set on
// its own doc. Entitlements live in the server-only `entitlements/premium` subdoc; no gate reads
// these root fields, so this is defense-in-depth, not a fix for a live hole. Keep this list in sync
// with `User.kt` + `FirestoreUserDataSource` if a new client-written field is added.
allow update: if isOwner(uid) allow update: if isOwner(uid)
&& !request.resource.data.diff(resource.data).affectedKeys().hasAny(['hasPremium']); && request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'email', 'displayName', 'photoUrl', 'sex', 'partnerId', 'coupleId',
'plan', 'createdAt', 'lastActiveAt', 'fcmToken',
'notifPartnerAnswered', 'notifChatMessage'
]);
// Entitlements written server-side only (RevenueCat webhook via Admin SDK). // Entitlements written server-side only (RevenueCat webhook via Admin SDK).
// Client needs read access so FirestoreEntitlementChecker can observe premium state. // Client needs read access so FirestoreEntitlementChecker can observe premium state.

View File

@ -0,0 +1,139 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onEntitlementChanged = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
/**
* Notifies the OTHER partner when a user GAINS premium, so couple-shared premium unlock isn't silent
* for the non-subscriber. (Future.md: `subscription_entitlement_changed`.)
*
* Path: users/{userId}/entitlements/premium (onWrite)
* Edge-triggered: fires only on the inactiveactive transition, so renewals / repeated writes don't
* re-notify. Skips if the partner already had premium (the couple was already unlocked).
*/
function isActive(data) {
if (!data)
return false;
if (data.premium !== true)
return false;
const expiresAt = data.expiresAt;
if (expiresAt && expiresAt.toMillis() <= Date.now())
return false;
return true;
}
async function collectTokens(db, userId) {
var _a;
const tokens = [];
const userDoc = await db.collection('users').doc(userId).get();
const legacy = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
if (typeof legacy === 'string' && legacy.length > 0)
tokens.push(legacy);
const tokSnap = await db.collection('users').doc(userId).collection('fcmTokens').get();
tokSnap.docs.forEach((d) => {
var _a;
const t = (_a = d.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t))
tokens.push(t);
});
return tokens;
}
exports.onEntitlementChanged = functions.firestore
.document('users/{userId}/entitlements/premium')
.onWrite(async (change, context) => {
var _a, _b, _c, _d;
const { userId } = context.params;
const before = isActive(change.before.data());
const after = isActive(change.after.data());
if (before || !after)
return; // only a genuine inactive→active gain
const db = admin.firestore();
const messaging = admin.messaging();
// Resolve this user's couple + partner.
const coupleSnap = await db
.collection('couples')
.where('userIds', 'array-contains', userId)
.limit(1)
.get();
if (coupleSnap.empty) {
console.log(`[onEntitlementChanged] no couple for ${userId}`);
return;
}
const coupleDoc = coupleSnap.docs[0];
const coupleId = coupleDoc.id;
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
const partnerId = userIds.find((id) => id !== userId);
if (!partnerId) {
console.log(`[onEntitlementChanged] no partner for ${userId} in ${coupleId}`);
return;
}
// If the partner already has premium the couple was already unlocked — nothing new to announce.
const partnerEnt = await db.doc(`users/${partnerId}/entitlements/premium`).get();
if (isActive(partnerEnt.data())) {
console.log(`[onEntitlementChanged] partner ${partnerId} already premium; skip`);
return;
}
const subscriberName = (_d = (_c = (await db.doc(`users/${userId}`).get()).data()) === null || _c === void 0 ? void 0 : _c.displayName) !== null && _d !== void 0 ? _d : 'Your partner';
const payload = {
type: 'subscription_entitlement_changed',
title: 'Premium unlocked ✨',
body: `${subscriberName} upgraded — you both have Premium now.`,
};
// In-app record for the partner.
await db
.collection('users')
.doc(partnerId)
.collection('notification_queue')
.add(Object.assign(Object.assign({}, payload), { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp() }));
const tokens = await collectTokens(db, partnerId);
if (tokens.length === 0) {
console.log(`[onEntitlementChanged] no FCM tokens for ${partnerId}`);
return;
}
const message = {
token: tokens[0],
notification: { title: payload.title, body: payload.body },
android: { notification: { channelId: 'partner_activity' } },
data: { type: payload.type, couple_id: coupleId },
};
const results = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, message), { token }))));
results.forEach((r, i) => {
if (r.status === 'rejected') {
console.warn(`[onEntitlementChanged] send failed ${tokens[i]}: ${String(r.reason)}`);
}
});
console.log(`[onEntitlementChanged] notified ${partnerId} of couple premium (${coupleId})`);
});
//# sourceMappingURL=onEntitlementChanged.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"onEntitlementChanged.js","sourceRoot":"","sources":["../../src/billing/onEntitlementChanged.ts"],"names":[],"mappings":"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"}

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
// Initialize the Admin SDK once for every function in this codebase. // Initialize the Admin SDK once for every function in this codebase.
// Handlers call admin.firestore()/messaging() lazily at invocation time, so a // Handlers call admin.firestore()/messaging() lazily at invocation time, so a
@ -45,6 +45,8 @@ var revenueCatWebhook_1 = require("./billing/revenueCatWebhook");
Object.defineProperty(exports, "revenueCatWebhook", { enumerable: true, get: function () { return revenueCatWebhook_1.revenueCatWebhook; } }); Object.defineProperty(exports, "revenueCatWebhook", { enumerable: true, get: function () { return revenueCatWebhook_1.revenueCatWebhook; } });
var syncEntitlement_1 = require("./billing/syncEntitlement"); var syncEntitlement_1 = require("./billing/syncEntitlement");
Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } }); Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } });
var onEntitlementChanged_1 = require("./billing/onEntitlementChanged");
Object.defineProperty(exports, "onEntitlementChanged", { enumerable: true, get: function () { return onEntitlementChanged_1.onEntitlementChanged; } });
var reminders_1 = require("./notifications/reminders"); var reminders_1 = require("./notifications/reminders");
Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, get: function () { return reminders_1.sendDailyQuestionReminder; } }); Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, get: function () { return reminders_1.sendDailyQuestionReminder; } });
Object.defineProperty(exports, "sendPartnerAnsweredNotification", { enumerable: true, get: function () { return reminders_1.sendPartnerAnsweredNotification; } }); Object.defineProperty(exports, "sendPartnerAnsweredNotification", { enumerable: true, get: function () { return reminders_1.sendPartnerAnsweredNotification; } });

View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"}

View File

@ -0,0 +1,115 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
/**
* Notifies the OTHER partner when a user GAINS premium, so couple-shared premium unlock isn't silent
* for the non-subscriber. (Future.md: `subscription_entitlement_changed`.)
*
* Path: users/{userId}/entitlements/premium (onWrite)
* Edge-triggered: fires only on the inactiveactive transition, so renewals / repeated writes don't
* re-notify. Skips if the partner already had premium (the couple was already unlocked).
*/
function isActive(data: FirebaseFirestore.DocumentData | undefined): boolean {
if (!data) return false
if (data.premium !== true) return false
const expiresAt = data.expiresAt as admin.firestore.Timestamp | undefined
if (expiresAt && expiresAt.toMillis() <= Date.now()) return false
return true
}
async function collectTokens(
db: admin.firestore.Firestore,
userId: string
): Promise<string[]> {
const tokens: string[] = []
const userDoc = await db.collection('users').doc(userId).get()
const legacy = userDoc.data()?.fcmToken
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy)
const tokSnap = await db.collection('users').doc(userId).collection('fcmTokens').get()
tokSnap.docs.forEach((d) => {
const t = d.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
})
return tokens
}
export const onEntitlementChanged = functions.firestore
.document('users/{userId}/entitlements/premium')
.onWrite(async (change, context) => {
const { userId } = context.params as { userId: string }
const before = isActive(change.before.data())
const after = isActive(change.after.data())
if (before || !after) return // only a genuine inactive→active gain
const db = admin.firestore()
const messaging = admin.messaging()
// Resolve this user's couple + partner.
const coupleSnap = await db
.collection('couples')
.where('userIds', 'array-contains', userId)
.limit(1)
.get()
if (coupleSnap.empty) {
console.log(`[onEntitlementChanged] no couple for ${userId}`)
return
}
const coupleDoc = coupleSnap.docs[0]
const coupleId = coupleDoc.id
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((id) => id !== userId)
if (!partnerId) {
console.log(`[onEntitlementChanged] no partner for ${userId} in ${coupleId}`)
return
}
// If the partner already has premium the couple was already unlocked — nothing new to announce.
const partnerEnt = await db.doc(`users/${partnerId}/entitlements/premium`).get()
if (isActive(partnerEnt.data())) {
console.log(`[onEntitlementChanged] partner ${partnerId} already premium; skip`)
return
}
const subscriberName =
(await db.doc(`users/${userId}`).get()).data()?.displayName ?? 'Your partner'
const payload = {
type: 'subscription_entitlement_changed',
title: 'Premium unlocked ✨',
body: `${subscriberName} upgraded — you both have Premium now.`,
}
// In-app record for the partner.
await db
.collection('users')
.doc(partnerId)
.collection('notification_queue')
.add({
...payload,
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
const tokens = await collectTokens(db, partnerId)
if (tokens.length === 0) {
console.log(`[onEntitlementChanged] no FCM tokens for ${partnerId}`)
return
}
const message: admin.messaging.Message = {
token: tokens[0],
notification: { title: payload.title, body: payload.body },
android: { notification: { channelId: 'partner_activity' } },
data: { type: payload.type, couple_id: coupleId },
}
const results = await Promise.allSettled(
tokens.map((token) => messaging.send({ ...message, token }))
)
results.forEach((r, i) => {
if (r.status === 'rejected') {
console.warn(`[onEntitlementChanged] send failed ${tokens[i]}: ${String(r.reason)}`)
}
})
console.log(`[onEntitlementChanged] notified ${partnerId} of couple premium (${coupleId})`)
})

View File

@ -9,6 +9,7 @@ if (admin.apps.length === 0) {
export { revenueCatWebhook } from './billing/revenueCatWebhook' export { revenueCatWebhook } from './billing/revenueCatWebhook'
export { syncEntitlement } from './billing/syncEntitlement' export { syncEntitlement } from './billing/syncEntitlement'
export { onEntitlementChanged } from './billing/onEntitlementChanged'
export { export {
sendDailyQuestionReminder, sendDailyQuestionReminder,
sendPartnerAnsweredNotification, sendPartnerAnsweredNotification,