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:
parent
9f09ebbc67
commit
4eed0a8115
|
|
@ -1,13 +1,14 @@
|
|||
# Closer — Branding & Artwork Review (Pass H)
|
||||
|
||||
Living deliverable for the **Branding pass**. It captures, screen by screen, where Closer could carry more of its
|
||||
brand, and gives **ready-to-paste ChatGPT image-generation prompts** for the artwork worth adding. The user generates
|
||||
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_*` /
|
||||
`pack_art_*` / `particle_*` assets).
|
||||
Living status document for Closer's brand artwork pass. It records which illustrations/glyphs are live, which surfaces
|
||||
should reuse existing assets, and which brand issues belong in implementation or QA rather than image generation.
|
||||
|
||||
**Current state:** no active image-generation prompts remain. A1-A12 illustrations and the G/G2 glyph sets exist; the
|
||||
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"
|
||||
> 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
|
||||
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
|
||||
+ 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)
|
||||
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.
|
||||
- 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
|
||||
`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:
|
||||
|
|
@ -79,15 +80,38 @@ The generated art (source in gitignored `docs/brand/generated-art/`, copied full
|
|||
| A11 `privacy_recovery` | SecurityScreen 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.
|
||||
**Not done:** A7 pack art = **N/A** (all 10 packs already have `pack_art_*`). **G-set glyph art generated
|
||||
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
|
||||
reachable on the baseline couple — render path proven via the shared tile + gallery. Commits `077a408`→`5868d06` on `dev`.
|
||||
All 12 also live in the debug **Art preview** gallery (Settings → Art preview) for both-theme verification. A7 pack art is
|
||||
**N/A** because all 10 question packs already have `pack_art_*`.
|
||||
|
||||
A1-A12 prompts are complete and should not be regenerated. Empty/match/pairing states that need empty-or-new data were
|
||||
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**,
|
||||
> 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 |
|
||||
|---|---|---|
|
||||
|
|
@ -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) |
|
||||
| Pair: invite (create code) | `illustration_couple_invite` 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 |
|
||||
| Today / daily question | clean card | ✅; reveal moment is the place for art (below) |
|
||||
| Answer reveal (mutual) | `illustration_reveal_celebration` 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 / 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 | ✅ |
|
||||
| Spin the Wheel | nice wheel art | ✅ wheel is on-brand |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
@ -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 | ✅ |
|
||||
| Your Progress / Activity | stats | 🔤 brand-colored charts; reuse `streak_milestone` for milestones |
|
||||
| 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 🔤 |
|
||||
| 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 | ✅ |
|
||||
| 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_*`.
|
||||
G-set glyphs were generated 2026-06-27 and are ready to add from `docs/brand/generated-art/glyphs/`. There are no active
|
||||
art-generation prompts right now.
|
||||
**None.** Do not regenerate completed illustration or glyph art unless a future QA pass logs a specific defect that
|
||||
requires replacement.
|
||||
|
||||
Do not regenerate completed illustration art unless a future QA pass logs a specific defect that requires replacement.
|
||||
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`,
|
||||
**Generated glyph 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_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.
|
||||
- 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.
|
||||
</content>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# 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 P0–P2). 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.
|
||||
|
|
@ -10,6 +12,7 @@
|
|||
> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
|
||||
|
||||
## 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 P0–P2.** 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 P0–P2 | 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 + 1–5 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 |
|
||||
| P1 | 0 | **1** (A-201) |
|
||||
| P2 | 0 | 0 |
|
||||
| P3 | **2** (J-OBS, C-ART-EDGE-002) | 0 |
|
||||
| P2 | **1** (C-DARK-UI-001) | 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 P0–P2 = FLAWLESS · A-201 P1 fixed+verified-live pending 1 confirm · 2 open P3 [J-OBS, C-ART-EDGE-002])
|
||||
> R12 was a FRESH FULL A–J run. Found A-201 (P1, Date Match premium bypass) — **fixed + verified live this round** (gated
|
||||
|
|
|
|||
53
Future.md
53
Future.md
|
|
@ -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).
|
||||
|
||||
- **Memory Lane capsule list: separate title from body preview.** (R12 Pass B) In the capsule list each row runs
|
||||
the title straight into the body preview with no separator/space/style break (e.g. "QA Memory Test" + "Remember this
|
||||
QA round…" renders as one run-on string). Give the preview its own line / muted style, or a separator, so title and
|
||||
body read distinctly. (Partly confounded by R12 adb-typed test titles — re-check with clean data; cosmetic only.)
|
||||
- **✅ DONE — Consistent brand glyphs across game cards + waiting surfaces.** G-set + G2 (17 glyphs) in
|
||||
`res/drawable-nodpi/glyph_*.xml`; **13 wired + verified live:** every Play-hub card (This or That, How Well, Desire
|
||||
Sync, Connection Challenges, Memory Lane, Date Match, Plan Date, Question Packs, Bucket List, Past Games — Spin the
|
||||
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
|
||||
(`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 —
|
||||
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.
|
||||
|
||||
### 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
|
||||
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.
|
||||
- **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.
|
||||
|
||||
<!--
|
||||
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:
|
||||
- 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).
|
||||
|
|
|
|||
|
|
@ -279,6 +279,14 @@ enum class PartnerNotificationType(
|
|||
body = "Tap to create a new invite.",
|
||||
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
|
||||
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
|
||||
REENGAGEMENT -> AppRoute.DAILY_QUESTION
|
||||
PARTNER_UNPAIRED -> AppRoute.HOME
|
||||
SUBSCRIPTION_CHANGED -> AppRoute.SUBSCRIPTION
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
@ -338,6 +347,7 @@ enum class PartnerNotificationType(
|
|||
"date_match" -> DATE_MATCH
|
||||
"reengagement" -> REENGAGEMENT
|
||||
"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
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -148,6 +148,20 @@ class AnswerRevealViewModel @Inject constructor(
|
|||
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
|
||||
|
|
|
|||
|
|
@ -11,11 +11,17 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import app.closer.ui.components.CloserHeartLoader
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -154,11 +160,20 @@ fun WaitingForPartnerScreen(
|
|||
)
|
||||
}
|
||||
else -> {
|
||||
CategoryGlyph(
|
||||
categoryId = gameTypeGlyphKey(state.gameType),
|
||||
size = 80.dp,
|
||||
iconSize = 38.dp
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.66f),
|
||||
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 = "Waiting for ${state.partnerName}",
|
||||
|
|
@ -235,10 +250,10 @@ private fun gameTypeRoute(gameType: String): String? = when (gameType) {
|
|||
else -> null
|
||||
}
|
||||
|
||||
private fun gameTypeGlyphKey(gameType: String): String = when (gameType) {
|
||||
GameType.WHEEL -> "play"
|
||||
GameType.THIS_OR_THAT -> "question"
|
||||
GameType.HOW_WELL -> "predict"
|
||||
GameType.DESIRE_SYNC -> "sex_and_desire"
|
||||
else -> "play"
|
||||
private fun gameTypeGlyphRes(gameType: String): Int = when (gameType) {
|
||||
GameType.WHEEL -> R.drawable.glyph_spin_wheel
|
||||
GameType.THIS_OR_THAT -> R.drawable.glyph_paired_cards
|
||||
GameType.HOW_WELL -> R.drawable.glyph_how_well
|
||||
GameType.DESIRE_SYNC -> R.drawable.glyph_sealed_answer
|
||||
else -> R.drawable.glyph_paired_cards
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -151,7 +152,7 @@ private fun PlayHubContent(
|
|||
CompactPlayCard(
|
||||
title = "Question Packs",
|
||||
subtitle = "Themed prompts to explore together",
|
||||
icon = Icons.Filled.Star,
|
||||
icon = ImageVector.vectorResource(R.drawable.glyph_question_packs),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { onNavigate(AppRoute.QUESTION_PACKS) }
|
||||
|
|
@ -166,7 +167,7 @@ private fun PlayHubContent(
|
|||
CompactPlayCard(
|
||||
title = "Date Match",
|
||||
subtitle = "Swipe ideas",
|
||||
icon = Icons.Filled.Favorite,
|
||||
icon = ImageVector.vectorResource(R.drawable.glyph_date_card_heart),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onPlay(AppRoute.DATE_MATCH) }
|
||||
|
|
@ -174,7 +175,7 @@ private fun PlayHubContent(
|
|||
CompactPlayCard(
|
||||
title = "Plan Date",
|
||||
subtitle = "Set the shape",
|
||||
icon = Icons.Filled.Star,
|
||||
icon = ImageVector.vectorResource(R.drawable.glyph_date_card_heart),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onPlay(AppRoute.DATE_BUILDER) }
|
||||
|
|
@ -190,7 +191,7 @@ private fun PlayHubContent(
|
|||
CompactPlayCard(
|
||||
title = "Bucket List",
|
||||
subtitle = "Save ideas",
|
||||
icon = Icons.Filled.Done,
|
||||
icon = ImageVector.vectorResource(R.drawable.glyph_bucket_list),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onPlay(AppRoute.BUCKET_LIST) }
|
||||
|
|
@ -198,7 +199,7 @@ private fun PlayHubContent(
|
|||
CompactPlayCard(
|
||||
title = "Past Games",
|
||||
subtitle = "All results",
|
||||
icon = Icons.Filled.Home,
|
||||
icon = ImageVector.vectorResource(R.drawable.glyph_past_games),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
locked = !hasPremium,
|
||||
modifier = Modifier.weight(1f),
|
||||
|
|
@ -236,10 +237,11 @@ private fun ThisOrThatCard(
|
|||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "A/B",
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.glyph_paired_cards),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -302,12 +304,20 @@ private fun DesireSyncCard(
|
|||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CategoryGlyph(
|
||||
categoryId = "sex_and_desire",
|
||||
modifier = Modifier.size(52.dp),
|
||||
size = 52.dp,
|
||||
iconSize = 25.dp
|
||||
Surface(
|
||||
shape = RoundedCornerShape(CloserRadii.Tile),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.78f),
|
||||
modifier = Modifier.size(52.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(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
|
|
@ -388,12 +398,20 @@ private fun HowWellCard(
|
|||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CategoryGlyph(
|
||||
categoryId = "predict",
|
||||
modifier = Modifier.size(52.dp),
|
||||
size = 52.dp,
|
||||
iconSize = 25.dp
|
||||
Surface(
|
||||
shape = RoundedCornerShape(CloserRadii.Tile),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.78f),
|
||||
modifier = Modifier.size(52.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(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
|
|
@ -461,9 +479,11 @@ private fun ConnectionChallengesCard(
|
|||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "🔗",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.glyph_connection_challenge),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -534,9 +554,11 @@ private fun MemoryLaneCard(
|
|||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "📦",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.glyph_memory_capsule),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import androidx.compose.material3.Button
|
|||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import app.closer.R
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import app.closer.ui.components.CloserHeartLoader
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
|
|
@ -515,7 +517,7 @@ fun SettingsScreen(
|
|||
|
||||
SettingsSection(title = "Premium", accent = Color(0xFFE7A2D1)) {
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Favorite,
|
||||
icon = ImageVector.vectorResource(R.drawable.glyph_couple_premium),
|
||||
label = "Subscription",
|
||||
subtitle = "Manage one plan for both partners",
|
||||
onClick = { onNavigate(AppRoute.SUBSCRIPTION) },
|
||||
|
|
@ -525,14 +527,14 @@ fun SettingsScreen(
|
|||
|
||||
SettingsSection(title = "Privacy and safety", accent = Color(0xFFD9B8FF)) {
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Lock,
|
||||
icon = ImageVector.vectorResource(R.drawable.glyph_privacy_lock),
|
||||
label = "Security",
|
||||
subtitle = "Recovery phrase and account protection",
|
||||
onClick = { onNavigate(AppRoute.SECURITY) }
|
||||
)
|
||||
SettingsSectionDivider()
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Lock,
|
||||
icon = ImageVector.vectorResource(R.drawable.glyph_privacy_lock),
|
||||
label = "Privacy & Terms",
|
||||
subtitle = "Your data, policies, and legal details",
|
||||
onClick = { onNavigate(AppRoute.PRIVACY) }
|
||||
|
|
@ -541,7 +543,7 @@ fun SettingsScreen(
|
|||
|
||||
SettingsSection(title = "Account", accent = Color(0xFFFFD9E8)) {
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Warning,
|
||||
icon = ImageVector.vectorResource(R.drawable.glyph_delete_account),
|
||||
label = "Delete account",
|
||||
subtitle = "Permanently remove your Closer account",
|
||||
onClick = { onNavigate(AppRoute.DELETE_ACCOUNT) },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -189,8 +189,17 @@ service cloud.firestore {
|
|||
);
|
||||
allow create: if isOwner(uid)
|
||||
&& !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)
|
||||
&& !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).
|
||||
// Client needs read access so FirestoreEntitlementChecker can observe premium state.
|
||||
|
|
|
|||
|
|
@ -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 inactive→active 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
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"onEntitlementChanged.js","sourceRoot":"","sources":["../../src/billing/onEntitlementChanged.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
};
|
||||
})();
|
||||
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"));
|
||||
// Initialize the Admin SDK once for every function in this codebase.
|
||||
// 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; } });
|
||||
var syncEntitlement_1 = require("./billing/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");
|
||||
Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, get: function () { return reminders_1.sendDailyQuestionReminder; } });
|
||||
Object.defineProperty(exports, "sendPartnerAnsweredNotification", { enumerable: true, get: function () { return reminders_1.sendPartnerAnsweredNotification; } });
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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 inactive→active 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})`)
|
||||
})
|
||||
|
|
@ -9,6 +9,7 @@ if (admin.apps.length === 0) {
|
|||
|
||||
export { revenueCatWebhook } from './billing/revenueCatWebhook'
|
||||
export { syncEntitlement } from './billing/syncEntitlement'
|
||||
export { onEntitlementChanged } from './billing/onEntitlementChanged'
|
||||
export {
|
||||
sendDailyQuestionReminder,
|
||||
sendPartnerAnsweredNotification,
|
||||
|
|
|
|||
Loading…
Reference in New Issue