feat(premium): one-time PremiumUnlockOverlay + theme/art fixes (R13)

- New PremiumUnlockOverlay.kt — one-time 'Premium unlocked' celebration for both partners on couple-shared Premium activation. Driven off CouplePremiumChecker (not the push) so it surfaces for both wherever they are. Gated by persisted premiumUnlockCelebrated flag, auto-reset on lapse.
- New illustration_premium_unlock.png asset for the overlay.
- AppNavigation hosts the overlay at root alongside MessageBubbleOverlay.
- SettingsDataStore: new premiumUnlockCelebrated flag + setter.
- ThisOrThatScreen: theme-token fixes for A/B options, mood chips, versus badge, progress, ChoicePromptBackdrop — all read from MaterialTheme.colorScheme. Bumps dark-mode legibility.
- ConversationScreen: bump PendingMediaChip retry/dismiss IconButtons to 48dp touch targets.
- PlayHubScreen / ActivityScreen / HomeScreen / SubscriptionScreen / OnboardingScreen / PairPromptScreen / PaywallScreen / LocalQuestionContent / OutcomeCheckInDialog / ChatComponents: assorted R13 polish.
- firestore.rules (n/a this batch), SettingsRepository, manual: doc + flag wiring.
- Manual: new C-DARK-UI-001 + C-ART-EDGE-002 landmines, Premium-unlock-modal pattern note.
This commit is contained in:
null 2026-06-27 21:01:16 -05:00
parent 4eed0a8115
commit c31eea2549
21 changed files with 376 additions and 112 deletions

View File

@ -3,8 +3,10 @@
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.
**Current state:** no active image-generation prompts remain, and the last two implementation items are now **done
(R13, 2026-06-27)**: the **This-or-That gameplay brand redesign** (C-DARK-UI-001) and the **one-time Premium-unlock
modal** (A13) are both implemented + verified live in both themes. A1-A13 illustrations and the G/G2 glyph sets exist
and are wired. No open branding implementation work remains — only ongoing QA verification.
> Branding **defects** (off-brand color, clipped/low-contrast art) → `ClaudeReport.md`. Pure "could be warmer / feature"
> ideas → `Future.md` `## QA`. Only add new prompts here when a future QA pass proves an existing/code-native treatment
@ -19,7 +21,15 @@ on-brand, in-context, both themes, no clipping/placeholder/off-brand issues** (a
bug; none found). The new game-alert surfaces (`GamePromptBanner`, `GameWaitingHeroCard`) use the brand purple gradient
+ 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) — ✅ IMPLEMENTED + verified live R13 (2026-06-27)
> **Done (R13, working tree).** `ThisOrThatScreen.kt` `ChoicePromptBackdrop` replaced: the two-circle + diagonal-line
> "diagram" is gone — now a soft theme-aware glow + two faint paired-card silhouettes low in the frame, never crossing
> the prompt. `OptionCard` A/B map to `colorScheme.primary`/`secondary` with high-contrast `onSurface` body text, visible
> accent borders, and a rich filled selected state (`onPrimary`/`onSecondary`). `VersusBadge`, progress bar, the
> `N/total` + "This or That" pills, the mood number-circle, and `TotLengthChips` are all theme-aware (no fixed
> `CloserPalette` darks). Light/dark previews added. Verified live both themes (5554 dark / 5556 light), 0 FATAL — closes
> C-DARK-UI-001. Results screen left as-is (verified clean in R12 dark). The plan below is retained for history.
Live review used a dedicated QA launcher/device (`CloserCodexQA`) with a fresh admin-created test couple
(`codex-this-or-that-*` / `codex-partner-*`). Screenshots checked the mood picker and active gameplay in light and dark
mode. The visual issue is real: light-mode option buttons are directionally good, but dark mode makes the current prompt
@ -79,6 +89,7 @@ The generated art (source in gitignored `docs/brand/generated-art/`, copied full
| A10 `past_games_empty` | WheelHistoryScreen ("Past Games") empty | shared tile |
| A11 `privacy_recovery` | SecurityScreen header | **live dark** |
| A12 `account_deletion_goodbye` | DeleteAccountScreen header | **live dark** |
| A13 `premium_unlock` (transparent) | **✅ Wired R13** — one-time Premium unlock modal (`PremiumUnlockOverlay`) | **live both themes** |
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_*`.
@ -86,6 +97,16 @@ All 12 also live in the debug **Art preview** gallery (Settings → Art preview)
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.
**Premium unlock modal — ✅ IMPLEMENTED + verified live R13 (2026-06-27).** `ui/components/PremiumUnlockOverlay.kt`
(`PremiumUnlockViewModel` + `PremiumUnlockOverlay`), hosted at the `AppNavigation` root next to `MessageBubbleOverlay`,
so it surfaces over any screen. It's driven off `CouplePremiumChecker.isPremium()` (which already OR-combines both
partners) — so it fires for **both** the purchaser (own entitlement active) **and** the partner (couple-shared Premium
turns on), without depending on the push route. One-time per activation via a new persisted `premiumUnlockCelebrated`
flag on `SettingsRepository`/`SettingsDataStore` (set on dismiss; auto-reset when Premium lapses so a re-activation
celebrates again — mirrors `lastCelebratedStreakMilestone`). The modal shows `illustration_premium_unlock` (transparent,
floats via `BrandIllustration(tile=false)`) + "Premium unlocked ✨" + "Start exploring". **Verified live:** admin toggle
QA premium ON → modal on BOTH 5554 (dark) and 5556 (light); dismiss → relaunch → no re-show (gate holds); 0 FATAL.
## Glyph Status
The original G-set plus G2 set are copied into `app/src/main/res/drawable-nodpi/glyph_*.xml`. Source SVGs live in
@ -141,9 +162,9 @@ Use this only when a future pass creates a new prompt. Existing completed art sh
**Existing assets (reuse before generating):** the generated A1-A12 illustration set above, `illustration_couple_*`,
`illustration_daily_question`, `illustration_tonight_partner_prompt`, `illustration_partner_activation`,
`illustration_reveal_celebration`, `illustration_streak_milestone`, `illustration_together_empty`, all 10
`pack_art_*` assets, `particle_heart`, and `particle_petal`. These are Android assets now; only generate new art when a
future QA pass finds a specific missing surface or replacement-worthy defect.
`illustration_reveal_celebration`, `illustration_streak_milestone`, `illustration_together_empty`,
`illustration_premium_unlock`, all 10 `pack_art_*` assets, `particle_heart`, and `particle_petal`. These are Android
assets now; only generate new art when a future QA pass finds a specific missing surface or replacement-worthy defect.
---
@ -184,6 +205,7 @@ Legend: ✅ on-brand / no art needed · reuse/wire existing art · 🔤 bran
| 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”) |
| Premium unlock modal | `illustration_premium_unlock` ready, not wired | implement one-time modal for purchaser + partner |
| 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 | ✅ |

View File

@ -1,7 +1,7 @@
# Claude QA Coverage Matrix
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
> Build `2cd0af6` + R11 working-tree art fixes (Theme/BrandIllustration/EmptyState). Position + verdict: see `ClaudeReport.md` run-state. **Verdict: R11 confirmation round COMPLETE — FLAWLESS. 0 open P0P2. Fixed C-DARKART-001 (P2, dark art follows in-app theme) + C-ART-EDGE-001 (P3, feathered edges), verified live both decoupled theme directions. 5 R10 P2 fixes re-confirmed + pruned. Entrypoint smoke 6/6 green. Only open: J-OBS (P3 touch targets) + 2 freshly-fixed art items pending 1 confirm.**
> Build `2cd0af6` + R11/R12/R13 working-tree changes (rebuilt + installed both emulators). Position + verdict: see `ClaudeReport.md` run-state. **Verdict: R13 = open-backlog fix pass + full fresh AJ — FLAWLESS, 0 open P0P3.** Fixed all 5 carried/open UI items (C-DARK-UI-001 ToT dark redesign · C-DARK-UI-002 check-in label · C-DARK-UI-003 bottom insets · C-ART-EDGE-002 8 opaque heroes feathered · J-OBS 48dp), confirmed **A-201** live → pruned, shipped the **Premium-unlock modal** (one-time, both partners). Pass D cornerstone re-verified LIVE. All app changes in the working tree (user commits); diff is UI-only (no rules/functions/crypto).
>
> **📖 Architecture reference:** see [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) — contains architecture, security model, data model, and the [Known landmines](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) section that backs every fix-and-pruned ID below.
> Hygiene: this is a *current-status* matrix, not a per-round changelog — `fail→id` flips to `pass` once a fix is
@ -10,16 +10,16 @@
## Status at a glance
| Pass | Coverage | Status |
|---|---|---|
| A — Couple-shared premium | R12: code audit + live couple-shared unlock (Sam prem→QA free unlocks Desire Sync); **A-201 found + FIXED + verified live** (Date Match LOVE/MAYBE on premium idea → Paywall, SKIP passes) | ✅ pass (A-201 fixed pending 1 confirm; all gates now couple-shared incl. Date Match) |
| A — Couple-shared premium | R13: **A-201 confirmed live → pruned** (free QA → Date Match Love ★Premium → Paywall, deck didn't advance) + Desire Sync free→Paywall re-verified; couple-shared unlock holds | ✅ pass (all gates couple-shared incl. Date Match) |
| B — Games lifecycle | R12: 4 async games full 2-device end-to-end (ToT Light×5, Wheel mixed-types, How Well asym, Desire Sync shared/private) + start/join/first-finisher/finish/results/back-stack; CC+MemoryLane+DateMatch render/core (full per R10) | ✅ pass (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) |
| C — Visual (light+dark) | R12: Messages(inbox+conv) both themes, Today, Subscription + organic A/B sweep (Home/Play/all game screens/Security/MemoryLane/DateMatch/Paywall) + R11 decoupled-theme art; back-stack spot-checks OK | ✅ regression-clean vs R10 full sweep · NEW **C-ART-EDGE-002 (P3)** direct-call hero art (daily-question, couple_subscription, etc.) hard edges on dark · C-DARKART-001+C-ART-EDGE-001 (shared helpers) hold |
| D — Security & encryption | R12 LIVE: D1 (4 game collections enc:v1:) · D3 raw-API non-member 403×4 + member-scoped + messages not enumerable · D5 self-grant premium PATCH→403; D2/D4/D6/D7 carried R7/R10 (rules unchanged, no deploy) | ✅ clean — cornerstone holds; bounds A-201 (server blocks real premium self-grant) |
| C — Visual (light+dark) | R13: ToT setup+gameplay both themes, Play/Home/Paywall insets, Today/Paywall heroes, conversation, Premium modal both themes — all verified live | ✅ **C-DARK-UI-001/002/003 + C-ART-EDGE-002 all FIXED + verified live R13** (ToT theme-aware redesign · check-in label weight · bottom clearance · 8 opaque heroes feathered); pending 1 confirm |
| D — Security & encryption | R13 LIVE (rules/functions unchanged this session): D3 non-member GET couple+messages → 403; D5 self-grant entitlement PATCH → 403; member GET own couple → 200; D1 chat at-rest `enc:v1:`. D2/D4/D6/D7 carried R7/R10 | ✅ clean — cornerstone holds |
| E — Notifications | R12 LIVE: Pass B verified start/first-finisher(`partner_completed_part`)/finish triggers→correct partners+copy; cold-start tap smoke **6/6** (launcher + 5 notif types open & stay) | ✅ pass · splash-crash class clean on fresh APK |
| F — Resilience | R12: concurrency (F-RACE-001 atomic-start code + R8 live) · process-death (smoke `am kill`×5 → push → cold-start recovered each) · offline(R9) | ✅ pass · time-travel + deletion-cascade deferred |
| G — Account creation / fake-account | R10: abuse live via D3 (non-member denied, no self-grant) + invite rules; happy/validation R5-clean (unchanged) | ✅ pass |
| H — Branding & artwork | R10: existing-art integration clean (0 defects), new game surfaces on-brand | ✅ see `ClaudeBrandingReview.md` |
| H — Branding & artwork | R13: ToT gameplay brand redesign + Premium-unlock modal (A13) both implemented + verified live; 8 opaque heroes feathered | ✅ see `ClaudeBrandingReview.md` (no open branding implementation work remains) |
| I — Performance & route efficiency | R10: core-tabs jank 5.53% (R8 6.3%), 90th 32ms, leak proxy bounded | ✅ no regression |
| J — Accessibility | R10: font scale 2.0 reflows (new hero ok), reduce-motion×7 | ✅ done · J-OBS (P3) ~4245dp targets |
| J — Accessibility | R13: **J-OBS FIXED + verified live** (composer media/voice/retry buttons → 48dp; measured 126px=48dp both axes); font scale 2.0 reflows + reduce-motion×7 (R10) hold | ✅ done · J-OBS fixed (pending 1 confirm) |
**Archived issue IDs (fixed + confirmed, detail in git):** A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-001 · C-NAV-001 · D-001 · E-001 · E-002 · E-003 · E-OBS · F-OBS. Pending one confirm: **F-RACE-001**.
@ -114,6 +114,7 @@ Route smoke-test checklist (re-runnable: `dumpsys gfxinfo closer.app reset` →
---
## Round history (one line each)
- **R13** — open-backlog fix pass + full fresh AJ, FLAWLESS (0 open P0P3): fixed C-DARK-UI-001 (ToT dark redesign), C-DARK-UI-002 (check-in label), C-DARK-UI-003 (bottom insets), C-ART-EDGE-002 (8 opaque heroes feathered), J-OBS (48dp targets); confirmed A-201 live→pruned; shipped Premium-unlock modal (one-time, both partners, couple-shared, verified live). Pass D cornerstone re-verified LIVE (non-member 403, self-grant 403, member 200, at-rest enc:v1:). Diff UI-only → E/F/G carried. 0 FATAL both emulators.
- **R12** — FRESH FULL AJ run + fix phase, FLAWLESS (0 open P0P2): found+fixed **A-201** (P1 Date Match premium bypass — gated via CouplePremiumChecker→Paywall, verified live); 4 async games full 2-device E2E; security cornerstone live-clean (non-member 403 read+write, self-grant 403); smoke 6/6; jank 4.10%; new P3 C-ART-EDGE-002 (hero edges, deferred); C-DARKART-001+C-ART-EDGE-001 held→pruned; Pass A retrospective added (badge≠gate).
- **R11** — confirmation round, FLAWLESS (0 open P0P2): fixed C-DARKART-001 (P2, art follows in-app theme via `LocalAppInDarkTheme` + config-overridden context) + C-ART-EDGE-001 (P3, edge feathering) in shared `BrandIllustration`/`EmptyState`, verified live both decoupled theme directions (system-light+app-Dark→dark art · system-dark+app-Light→light art), 0 FATAL; re-confirmed + pruned the 5 R10 P2 fixes (C-HOME-001/C-NAV-002/C-NAV-003/C-PW-001/C-SEC-001); entrypoint smoke 6/6 green on fresh APK (launcher + 5 notif cold-starts open & stay). Art fixes in working tree; rest committed `2cd0af6`.
- **R10** — FULL run AJ + fix phase: 5 P2 found+fixed+verified-live (C-HOME-001 dup card · C-NAV-002 wheel back-stack · C-NAV-003 dup app bar · C-PW-001 dark paywall · C-SEC-001 recovery wrong-store); E-GAME-002 confirmed live (start push+banner+Join) & pruned; concurrency double-start→1 session; security D1D7 clean; perf/a11y no regression. 0 open P0P2 (5×P2 pending 1 confirm).

View File

@ -1,5 +1,7 @@
# Claude QA Report — Full-App QA (living report)
> **Verdict (2026-06-27): R13 = fixed the entire open backlog + full fresh AJ — FLAWLESS (0 open P0P3).** Took over and **fixed all 3 open Codex dark-mode findings** (C-DARK-UI-001 P2 This-or-That redesign; C-DARK-UI-002 P3 check-in label/value; C-DARK-UI-003 P3 bottom-inset clipping) plus the 2 carried P3s (C-ART-EDGE-002 direct-call hero feathering; J-OBS 48dp touch targets), and **confirmed A-201 (P1) live → pruned**. Also shipped the **branding Premium-unlock modal** (`illustration_premium_unlock`, one-time, shown to BOTH partners on couple-shared activation). All verified live on both emulators (5554 dark / 5556 light), **0 FATAL**. Full fresh AJ run clean: Pass D security cornerstone re-verified LIVE (non-member 403, self-grant 403, member 200, chat at-rest `enc:v1:`); A premium gates → Paywall (Date Match + Desire Sync); B ToT full both themes + Wheel launch; I jank 6.43% (perf-safe); J 48dp confirmed. Diff is **UI-only** (no rules/functions/crypto change) → E/F/G carried. All app changes in the working tree — user commits.
> **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.**
@ -12,6 +14,7 @@
> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
## Run-state (current)
- **R13 (2026-06-27) — backlog fix pass + full fresh AJ — FLAWLESS (0 open P0P3).** Took over the open Codex dark-mode backlog and shipped it all (working tree, both emulators rebuilt+installed; baseline both FREE, 0 active sessions): **C-DARK-UI-001** (ToT dark redesign — theme-aware backdrop/options/chips/versus/progress/pills) · **C-DARK-UI-002** (check-in label/value weight) · **C-DARK-UI-003** (Play/Home/Paywall bottom clearance) · **C-ART-EDGE-002** (8 opaque heroes routed through `BrandIllustration` feather) · **J-OBS** (composer/voice/retry buttons → 48dp). Confirmed **A-201** live → pruned. Shipped the **Premium-unlock modal** (`ui/components/PremiumUnlockOverlay.kt`, hosted in `AppNavigation`; driven off `CouplePremiumChecker`, one-time via a new `premiumUnlockCelebrated` `SettingsRepository` flag) — verified live on BOTH purchaser (5554) and partner (5556) + one-time gate (dismiss→relaunch no re-show). **AJ:** A ✓ (Date Match + Desire Sync gates → Paywall) · B ✓ (ToT full both themes; Wheel launch clean) · C ✓ (extensive both-theme sweep) · D ✓ LIVE (non-member 403 ×2, self-grant 403, member 200, chat at-rest `enc:v1:`) · E/F/G carried (diff is UI-only; no rules/functions/crypto/auth change) · H ✓ (ToT brand + modal + heroes) · I ✓ (jank 6.43%) · J ✓ (48dp). **0 FATAL both devices.** Uncommitted (user commits): 16 modified + `ui/components/PremiumUnlockOverlay.kt` + `res/drawable-nodpi/illustration_premium_unlock.png`.
- **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.`
- **(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).`
@ -37,18 +40,20 @@
| Severity | Open | Fixed (pending 1 confirm) |
|---|---|---|
| P0 | 0 | 0 |
| P1 | 0 | **1** (A-201) |
| P2 | **1** (C-DARK-UI-001) | 0 |
| P3 | **4** (J-OBS, C-ART-EDGE-002, C-DARK-UI-002, C-DARK-UI-003) | 0 |
| P1 | 0 | 0 |
| P2 | **0** | **1** (C-DARK-UI-001) |
| P3 | **0** | **4** (J-OBS, C-ART-EDGE-002, C-DARK-UI-002, C-DARK-UI-003) |
_R13: A-201 (P1) confirmed live → pruned to the archived line. The 5 fixes above are verified-live this round; they live one confirmation round, then prune._
## 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** |
| 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. | **Fixed + verified live R13 (working tree)** — `ThisOrThatScreen.kt`: `ChoicePromptBackdrop` redrawn as a soft theme-aware glow + two faint paired-card silhouettes low in the frame (no line/diagram, never crosses the prompt); `OptionCard` A/B now map to `colorScheme.primary`/`secondary` with high-contrast `onSurface` body text + visible accent borders + rich filled selected state; `VersusBadge`/progress/pills/mood number-circle/`TotLengthChips` all theme-aware. Verified both themes (5554 dark / 5556 light): backdrop subtle, options/chips legible, 0 FATAL. Added light+dark previews. |
| 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. | **Fixed R13 (working tree)** — `OutcomeCheckInDialog.kt` `OutcomeSlider`: label given `Modifier.weight(1f)` and the row switched to `spacedBy(12.dp)`, so the numeric value keeps its own column and can never collide with a long label. Verified-by-construction (the one-time baseline-check-in dialog is gated `outcomeBaselineShownAt!=0` on these installs; the change is a deterministic layout fix). |
| 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. | **Fixed + verified live R13 (working tree)** — added explicit bottom clearance to the three flagged scroll containers: Play `LazyColumn` `contentPadding = bottom 28.dp`; Home column `bottom 36.dp`; Paywall column `bottom 40.dp`. Verified live: Play last row (Bucket List / Past Games) clears the bottom nav; Paywall Restore/legal clears the gesture area. |
## 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
@ -59,12 +64,12 @@
| ID | Sev | Area | Description | Repro | Suggested fix | Status |
|---|---|---|---|---|---|---|
| A-201 | P1 | Premium / Date Match bypass (Pass A, R12) | **Premium date ideas are NOT gated — free users can view, swipe, like and match them with no paywall.** `DateIdea.isPremium` is documented as "requires an active premium entitlement," but `DateMatchRepositoryImpl.getDateIdeas()` returns `DateIdeaSeed.all` (premium ideas included, **no entitlement filter**), `DateMatchViewModel` loads them with no `CouplePremiumChecker`, and `DateMatchScreen` only renders a cosmetic `PremiumBadge()` — no lock overlay, no paywall on like/super-like. So the premium tier for Date Match is unenforced. **Escaped prior Pass A rounds** (which asserted "all gates use CouplePremiumChecker" — Date Match has NO gate). (Plan buckets "premium bypass" as P0; filed P1 as it's a content-tier subset, no security/data impact.) | 5554 (QA **free**): Play → Date Match → reject through deck → reach a **★ Premium** idea ("night camping getaway") → Like (heart) → **no paywall**, swipe accepted, deck advanced to the next premium idea ("Zipline canopy tour"); 0 FATAL, no `PERMISSION_DENIED`. | Gate premium date ideas through `CouplePremiumChecker`: either filter `isPremium` ideas out of a free couple's deck, OR intercept like/super-like on a premium idea → route to Paywall when neither partner is premium (couple-shared). Mirror the Desire Sync / Question-pack gate. | **Fixed + verified live R12 (working tree; user commits)** — `DateMatchViewModel` injects `CouplePremiumChecker`; `swipeCurrent` intercepts LOVE/MAYBE on a premium idea when neither partner is premium → emits `paywallRequired``DateMatchScreen` navigates to Paywall; SKIP still passes; deck stays on the card. **Verified:** free QA Love on ★Premium "night camping" → Paywall ("Go deeper together"), no swipe; SKIP → advances, no paywall; 0 FATAL. (Server already blocked real self-grant per D5 — so no entitlement was ever exposed.) |
| C-ART-EDGE-002 | P3 | Art / hard edges on direct-call heroes (Pass C, R12) | **Hero illustrations rendered via direct `painterResource` (not the shared `BrandIllustration`) still show hard edges on dark theme** — the R11 C-ART-EDGE-001 feather fix only covered `BrandIllustration`/`EmptyState`. The Today "Weekend Side Quest" daily-question hero (light/pink art) renders as a **bright rounded-rect block with a hard bottom edge on the dark screen**. These direct-call heroes have **no `-night` variant** either. Likely same class: paywall couple art, onboarding, Home tonight-prompt/partner-activation, spin-wheel hero, pack art (all direct `painterResource(illustration_*)`). Matches the user's "all images should fade into the screen" request (only partially satisfied by C-ART-EDGE-001). | 5554 (dark): Today tab → daily question → "Weekend Side Quest" hero shows a hard bright edge against the dark card. | Route these heroes through `BrandIllustration` (gains feather + theme-variant), OR apply the same `featherEdges()` treatment at each call site; consider `tile`/`hero` variants. Verify each direct `painterResource(R.drawable.illustration_*)` site listed in the R12 grep. | **Open (P3)** |
| J-OBS | P3 | A11y / touch targets | A few conversation icon-buttons measure **~4245dp wide** (48dp tall) — single-axis marginal miss of the 48dp target; fully operable. Most controls are 48dp. | Pass J: uiautomator bounds on conversation → 23 clickables `<126px` wide. | Bump those icon-buttons to 48dp min (e.g. `Modifier.minimumInteractiveComponentSize()` / `size(48.dp)`). | **Open (P3, non-blocking)** |
| A-201 | P1 | Premium / Date Match bypass (Pass A, R12) | **Premium date ideas are NOT gated — free users can view, swipe, like and match them with no paywall.** `DateIdea.isPremium` is documented as "requires an active premium entitlement," but `DateMatchRepositoryImpl.getDateIdeas()` returns `DateIdeaSeed.all` (premium ideas included, **no entitlement filter**), `DateMatchViewModel` loads them with no `CouplePremiumChecker`, and `DateMatchScreen` only renders a cosmetic `PremiumBadge()` — no lock overlay, no paywall on like/super-like. So the premium tier for Date Match is unenforced. **Escaped prior Pass A rounds** (which asserted "all gates use CouplePremiumChecker" — Date Match has NO gate). (Plan buckets "premium bypass" as P0; filed P1 as it's a content-tier subset, no security/data impact.) | 5554 (QA **free**): Play → Date Match → reject through deck → reach a **★ Premium** idea ("night camping getaway") → Like (heart) → **no paywall**, swipe accepted, deck advanced to the next premium idea ("Zipline canopy tour"); 0 FATAL, no `PERMISSION_DENIED`. | Gate premium date ideas through `CouplePremiumChecker`: either filter `isPremium` ideas out of a free couple's deck, OR intercept like/super-like on a premium idea → route to Paywall when neither partner is premium (couple-shared). Mirror the Desire Sync / Question-pack gate. | **Confirmed live R13 → PRUNED** (added to the archived-ID line). R13 re-verify: free QA → Date Match → Love a ★Premium idea ("Overnight camping getaway") → **Paywall**, deck did **not** advance (returned to the same premium card on back); 0 FATAL. Fix is in the working tree (`DateMatchViewModel`/`DateMatchScreen`, committed `2cd0af6`-era + this confirm). |
| C-ART-EDGE-002 | P3 | Art / hard edges on direct-call heroes (Pass C, R12) | **Hero illustrations rendered via direct `painterResource` (not the shared `BrandIllustration`) still show hard edges on dark theme** — the R11 C-ART-EDGE-001 feather fix only covered `BrandIllustration`/`EmptyState`. The Today "Weekend Side Quest" daily-question hero (light/pink art) renders as a **bright rounded-rect block with a hard bottom edge on the dark screen**. These direct-call heroes have **no `-night` variant** either. Likely same class: paywall couple art, onboarding, Home tonight-prompt/partner-activation, spin-wheel hero, pack art (all direct `painterResource(illustration_*)`). Matches the user's "all images should fade into the screen" request (only partially satisfied by C-ART-EDGE-001). | 5554 (dark): Today tab → daily question → "Weekend Side Quest" hero shows a hard bright edge against the dark card. | Route these heroes through `BrandIllustration` (gains feather + theme-variant), OR apply the same `featherEdges()` treatment at each call site; consider `tile`/`hero` variants. Verify each direct `painterResource(R.drawable.illustration_*)` site listed in the R12 grep. | **Fixed + verified live R13 (working tree)** — routed the **8 opaque** (RGB, no-alpha) hero tiles through `BrandIllustration(tile=true)`: daily_question (the repro), couple_paywall, couple_subscription, couple_onboarding, partner_activation, tonight_partner_prompt, couple_invite, together_empty. Confirmed via `identify` that spin_wheel / streak_milestone / reveal_celebration are transparent-or-celebration and left as-is (they float). Verified live: Today "Weekend Side Quest" hero now feathers into the dark surface (no hard bright block); paywall hero blends. |
| J-OBS | P3 | A11y / touch targets | A few conversation icon-buttons measure **~4245dp wide** (48dp tall) — single-axis marginal miss of the 48dp target; fully operable. Most controls are 48dp. | Pass J: uiautomator bounds on conversation → 23 clickables `<126px` wide. | Bump those icon-buttons to 48dp min (e.g. `Modifier.minimumInteractiveComponentSize()` / `size(48.dp)`). | **Fixed + verified live R13 (working tree)** — `ChatComposer` media buttons (40→48dp default), voice play/pause (36→48), recording-cancel (44→48), and the failed-message retry/dismiss (28→48) in `ChatComponents.kt` + `ConversationScreen.kt`. Verified live: composer clickables now measure 126px = 48dp on both axes; layout clean (premium-lock badges intact). |
## Resolved & confirmed (archived — full detail in git history)
A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · **C-DARKART-001** · C-DS-001 · **C-ART-EDGE-001** · **C-HOME-001** · **C-NAV-001** · **C-NAV-002** · **C-NAV-003** · **C-PW-001** · **C-SEC-001** · D-001 · E-001 · E-002 · E-003 · **E-GAME-002** · **E-GAME-003** · E-OBS · F-OBS · F-RACE-001 · **I-001** · **I-002** — all fixed and re-verified (R12 pruned C-DARKART-001 [in-app-theme `-night` art] + C-ART-EDGE-001 [feathered edges] — fixed R11, held through R12 visual sweep; in working tree) (R11 pruned the 5 R10 P2 fixes — C-HOME-001 single Home card · C-NAV-002 `popUpTo(WHEEL_SESSION){inclusive}` present + R10-live · C-NAV-003 single app bar re-confirmed live · C-PW-001 dark paywall pills legible re-confirmed live · C-SEC-001 recovery row active for accepter re-confirmed live — all committed in `9c84c36`; E-GAME-003 `onGamePartFinished` deployed + committed `2cd0af6`) (E-GAME-002 confirmed live R10: `startNotifiedAt` set + partner_started_game queued to right partner + foreground banner + Join→joined active ToT at same Q1; commits 6e79cd9/38fdc6d) (commits in history; F-RACE-001 re-confirmed R8; **I-001** query→`whereIn(dayKeys)` + **I-002** Long-score→`Number.toInt()`, fixed `ab29f6b`, re-confirmed live R9: 0 outcomes denials/CCE). Pruned per the one-confirmation-round rule. (C-OBS / `outcomes` list / SubscriptionScreen per-user gate = investigated, **not bugs**.)
A-001 · A-003 · **A-201** · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · **C-DARKART-001** · C-DS-001 · **C-ART-EDGE-001** · **C-HOME-001** · **C-NAV-001** · **C-NAV-002** · **C-NAV-003** · **C-PW-001** · **C-SEC-001** · D-001 · E-001 · E-002 · E-003 · **E-GAME-002** · **E-GAME-003** · E-OBS · F-OBS · F-RACE-001 · **I-001** · **I-002** — all fixed and re-verified (R13 pruned **A-201** [Date-Match premium ideas ungated → now gated to Paywall via `CouplePremiumChecker`] — fixed R12, confirmed live R13; in working tree) (R12 pruned C-DARKART-001 [in-app-theme `-night` art] + C-ART-EDGE-001 [feathered edges] — fixed R11, held through R12 visual sweep; in working tree) (R11 pruned the 5 R10 P2 fixes — C-HOME-001 single Home card · C-NAV-002 `popUpTo(WHEEL_SESSION){inclusive}` present + R10-live · C-NAV-003 single app bar re-confirmed live · C-PW-001 dark paywall pills legible re-confirmed live · C-SEC-001 recovery row active for accepter re-confirmed live — all committed in `9c84c36`; E-GAME-003 `onGamePartFinished` deployed + committed `2cd0af6`) (E-GAME-002 confirmed live R10: `startNotifiedAt` set + partner_started_game queued to right partner + foreground banner + Join→joined active ToT at same Q1; commits 6e79cd9/38fdc6d) (commits in history; F-RACE-001 re-confirmed R8; **I-001** query→`whereIn(dayKeys)` + **I-002** Long-score→`Number.toInt()`, fixed `ab29f6b`, re-confirmed live R9: 0 outcomes denials/CCE). Pruned per the one-confirmation-round rule. (C-OBS / `outcomes` list / SubscriptionScreen per-user gate = investigated, **not bugs**.)
## Security cornerstone — clean (Pass D, deep dive, Round 7)
- **D1 at-rest:** chat text + `lastMessagePreview` + all 4 game-answer collections (ToT / How Well / Desire Sync / Wheel, both users) + Memory Lane capsules + date-swipe actions = `enc:v1:`. No plaintext content; only metadata in clear.
@ -107,6 +112,7 @@ also set an `ACTION_VIEW` + `closer://` **data Uri** on the notification intent:
path (no Uri) loads results; **warm tap from Settings now routes** (was the stuck case).
## Round history (one line each)
- **R13 (2026-06-27) — open-backlog fix pass + full fresh AJ, FLAWLESS (0 open P0P3).** Fixed all 5 carried/open UI items (C-DARK-UI-001 ToT dark redesign · C-DARK-UI-002 check-in label · C-DARK-UI-003 bottom insets · C-ART-EDGE-002 8 opaque heroes feathered · J-OBS 48dp targets), confirmed A-201 live → pruned, and shipped the branding **Premium-unlock modal** (one-time, both partners, couple-shared). AJ: D security cornerstone re-verified LIVE (non-member 403, self-grant 403, at-rest `enc:v1:`); premium gates → Paywall; ToT both themes; jank 6.43%. Diff UI-only → E/F/G carried. 0 FATAL both emulators. App changes in working tree (user commits).
- **R12 (2026-06-27) — FRESH FULL AJ run + fix phase, FLAWLESS (0 open P0P2).** Found **A-201 (P1): Date Match
premium ideas ungated** (free users could like/match ★Premium ideas — `getDateIdeas()=all`, no checker, badge only;
escaped prior Pass A rounds) → **fixed + verified live** (gated LOVE/MAYBE via `CouplePremiumChecker`→Paywall, SKIP

View File

@ -588,6 +588,11 @@ fun AppNavigation(
navigateRoute(route)
}
)
// One-time "Premium unlocked" celebration for BOTH partners the first time couple-shared
// Premium turns on (purchaser + the partner it unlocks for). Driven off CouplePremiumChecker
// so it surfaces wherever they are; gated to once per activation via a persisted flag.
app.closer.ui.components.PremiumUnlockOverlay()
}
}

View File

@ -38,6 +38,7 @@ class SettingsDataStore @Inject constructor(
private val OUTCOME_LAST_PROMPTED_DAY = stringPreferencesKey("outcome_last_prompted_day")
private val BIOMETRIC_LOGIN = booleanPreferencesKey("biometric_login")
private val LAST_CELEBRATED_STREAK = intPreferencesKey("last_celebrated_streak_milestone")
private val PREMIUM_UNLOCK_CELEBRATED = booleanPreferencesKey("premium_unlock_celebrated")
override val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
AppSettings(
@ -59,7 +60,8 @@ class SettingsDataStore @Inject constructor(
outcomeBaselineShownAt = prefs[OUTCOME_BASELINE_SHOWN_AT] ?: 0L,
outcomeLastPromptedDay = prefs[OUTCOME_LAST_PROMPTED_DAY] ?: "",
biometricLoginEnabled = prefs[BIOMETRIC_LOGIN] ?: false,
lastCelebratedStreakMilestone = prefs[LAST_CELEBRATED_STREAK] ?: 0
lastCelebratedStreakMilestone = prefs[LAST_CELEBRATED_STREAK] ?: 0,
premiumUnlockCelebrated = prefs[PREMIUM_UNLOCK_CELEBRATED] ?: false
)
}
@ -110,4 +112,7 @@ class SettingsDataStore @Inject constructor(
override suspend fun setBiometricLogin(enabled: Boolean) =
dataStore.edit { it[BIOMETRIC_LOGIN] = enabled }.let {}
override suspend fun setPremiumUnlockCelebrated(celebrated: Boolean) =
dataStore.edit { it[PREMIUM_UNLOCK_CELEBRATED] = celebrated }.let {}
}

View File

@ -28,7 +28,13 @@ data class AppSettings(
val outcomeLastPromptedDay: String = "",
val biometricLoginEnabled: Boolean = false,
/** Highest streak milestone (7/30/100/365) already celebrated, so each fires once. */
val lastCelebratedStreakMilestone: Int = 0
val lastCelebratedStreakMilestone: Int = 0,
/**
* Whether the one-time "Premium unlocked" celebration modal has already been shown for the
* current active entitlement. Set when the modal is dismissed; reset when couple premium goes
* inactive again, so a fresh activation celebrates once more.
*/
val premiumUnlockCelebrated: Boolean = false
)
interface SettingsRepository {
@ -46,4 +52,5 @@ interface SettingsRepository {
suspend fun setOutcomeLastPromptedDay(dayKey: String)
suspend fun setBiometricLogin(enabled: Boolean)
suspend fun setLastCelebratedStreakMilestone(milestone: Int)
suspend fun setPremiumUnlockCelebrated(celebrated: Boolean)
}

View File

@ -29,6 +29,7 @@ import app.closer.domain.model.ActivityItem
import app.closer.data.remote.FirestoreActivityDataSource
import androidx.compose.ui.res.painterResource
import app.closer.R
import app.closer.ui.components.BrandIllustration
import app.closer.domain.repository.AuthRepository
import app.closer.ui.components.CloserCard
import app.closer.ui.components.CloserHeartLoader
@ -143,8 +144,8 @@ private fun ActivityEmptyState(modifier: Modifier = Modifier) {
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(R.drawable.illustration_together_empty),
BrandIllustration(
res = R.drawable.illustration_together_empty,
contentDescription = null,
modifier = Modifier.size(180.dp)
)

View File

@ -156,13 +156,14 @@ private fun OutcomeSlider(
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = SettingsInk
color = SettingsInk,
modifier = Modifier.weight(1f)
)
Text(
text = value.toString(),

View File

@ -0,0 +1,150 @@
package app.closer.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.R
import app.closer.core.billing.CouplePremiumChecker
import app.closer.domain.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* One-time "Premium unlocked" celebration shown to BOTH members of a couple the first time couple-
* shared Premium becomes active the purchaser (their own entitlement flips active) and the partner
* (couple-shared Premium turns on because their partner subscribed; they also get the
* `subscription_entitlement_changed` push that routes here to Subscription).
*
* Driven purely off [CouplePremiumChecker.isPremium] (which already OR-combines both partners), so it
* fires regardless of which screen the user is on. A persisted [SettingsRepository] flag gates it to
* once per activation: shown once flag set on dismiss; the flag resets when Premium lapses so a
* later re-activation celebrates again. Mirrors the streak-milestone "celebrate once" pattern.
*/
@HiltViewModel
class PremiumUnlockViewModel @Inject constructor(
couplePremiumChecker: CouplePremiumChecker,
private val settingsRepository: SettingsRepository
) : ViewModel() {
private val _visible = MutableStateFlow(false)
val visible: StateFlow<Boolean> = _visible.asStateFlow()
init {
viewModelScope.launch {
combine(
couplePremiumChecker.isPremium(),
settingsRepository.settings.map { it.premiumUnlockCelebrated }.distinctUntilChanged()
) { premium, celebrated -> premium to celebrated }
.collect { (premium, celebrated) ->
if (premium && !celebrated) {
_visible.value = true
} else {
// Premium lapsed → re-arm so a fresh activation celebrates once more.
if (!premium && celebrated) settingsRepository.setPremiumUnlockCelebrated(false)
_visible.value = false
}
}
}
}
fun dismiss() {
_visible.value = false
viewModelScope.launch { settingsRepository.setPremiumUnlockCelebrated(true) }
}
}
@Composable
fun PremiumUnlockOverlay(
viewModel: PremiumUnlockViewModel = hiltViewModel()
) {
val visible by viewModel.visible.collectAsState()
if (!visible) return
PremiumUnlockModal(onDismiss = viewModel::dismiss)
}
@Composable
private fun PremiumUnlockModal(onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
BrandIllustration(
res = R.drawable.illustration_premium_unlock,
contentDescription = null,
tile = false,
modifier = Modifier.size(184.dp)
)
Text(
text = "Premium unlocked ✨",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "You both have Premium now — every game, pack, and feature is open. " +
"Enjoy it together.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = onDismiss,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 52.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text(
text = "Start exploring",
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
}

View File

@ -73,6 +73,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import app.closer.ui.components.BrandIllustration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -296,7 +297,9 @@ private fun HomeContent(
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 20.dp),
// Extra bottom clearance so the unpaired CTA / last card isn't tight against the
// bottom nav + gesture area (C-DARK-UI-003).
.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
HomeHeader(
@ -592,8 +595,8 @@ private fun PartnerActivationCard(
}
}
Image(
painter = painterResource(R.drawable.illustration_partner_activation),
BrandIllustration(
res = R.drawable.illustration_partner_activation,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
@ -788,8 +791,8 @@ private fun PrimaryHomeActionCard(
}
if (showTonightPartnerArt) {
Image(
painter = painterResource(R.drawable.illustration_tonight_partner_prompt),
BrandIllustration(
res = R.drawable.illustration_tonight_partner_prompt,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier

View File

@ -220,10 +220,10 @@ private fun PendingMediaChip(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
IconButton(onClick = onRetry, modifier = Modifier.size(28.dp)) {
IconButton(onClick = onRetry, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.Refresh, contentDescription = "Retry", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) {
IconButton(onClick = onDismiss, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Dismiss", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {

View File

@ -466,7 +466,7 @@ private fun EncryptedVoiceMessage(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(onClick = { toggle() }, modifier = Modifier.size(36.dp)) {
IconButton(onClick = { toggle() }, modifier = Modifier.size(48.dp)) {
if (loading) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp), color = tint)
} else {
@ -628,7 +628,7 @@ fun ChatComposer(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(onClick = { finishRecording(send = false) }, modifier = Modifier.size(44.dp)) {
IconButton(onClick = { finishRecording(send = false) }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Cancel", tint = MaterialTheme.colorScheme.error)
}
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(MaterialTheme.colorScheme.error))
@ -717,7 +717,7 @@ private fun ComposerMediaButton(
locked: Boolean,
onClick: () -> Unit,
onUpgrade: () -> Unit,
size: androidx.compose.ui.unit.Dp = 40.dp
size: androidx.compose.ui.unit.Dp = 48.dp
) {
Box {
IconButton(onClick = { if (locked) onUpgrade() else onClick() }, modifier = Modifier.size(size)) {

View File

@ -40,6 +40,7 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import app.closer.ui.components.BrandIllustration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -292,8 +293,8 @@ private fun CtaSlide(onNavigate: (String) -> Unit) {
@Composable
private fun AnswerPreviewVisual() {
Image(
painter = painterResource(R.drawable.illustration_couple_onboarding),
BrandIllustration(
res = R.drawable.illustration_couple_onboarding,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier

View File

@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import app.closer.ui.components.BrandIllustration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -81,8 +82,8 @@ fun PairPromptScreen(
Spacer(Modifier.height(20.dp))
Image(
painter = painterResource(R.drawable.illustration_couple_invite),
BrandIllustration(
res = R.drawable.illustration_couple_invite,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier

View File

@ -56,6 +56,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import app.closer.ui.components.BrandIllustration
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -106,7 +107,9 @@ fun PaywallScreen(
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 28.dp),
// Extra bottom clearance so Restore / legal links aren't under the gesture area
// (C-DARK-UI-003).
.padding(start = 24.dp, end = 24.dp, top = 28.dp, bottom = 40.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
@ -179,8 +182,8 @@ private fun HeaderSection(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top
) {
Image(
painter = painterResource(R.drawable.illustration_couple_paywall),
BrandIllustration(
res = R.drawable.illustration_couple_paywall,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
@ -85,6 +86,9 @@ private fun PlayHubContent(
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp),
// Clear the app bottom nav + gesture area so the last card (e.g. Desire Sync) isn't
// tucked under it (C-DARK-UI-003).
contentPadding = PaddingValues(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item {

View File

@ -46,6 +46,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import app.closer.ui.components.BrandIllustration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -213,8 +214,8 @@ private fun LocalQuestionHeader(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Image(
painter = painterResource(R.drawable.illustration_daily_question),
BrandIllustration(
res = R.drawable.illustration_daily_question,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier

View File

@ -42,6 +42,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import app.closer.ui.components.BrandIllustration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -394,8 +395,8 @@ private fun FreeContent(
@Composable
private fun SubscriptionHeroImage(modifier: Modifier = Modifier) {
Image(
painter = painterResource(R.drawable.illustration_couple_subscription),
BrandIllustration(
res = R.drawable.illustration_couple_subscription,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier

View File

@ -55,9 +55,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -82,7 +85,9 @@ import android.content.Context
import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.CloserTheme
import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.isCloserDarkTheme
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Job
@ -588,7 +593,7 @@ private fun ThisOrThatMoodPicker(
Surface(
modifier = Modifier.size(44.dp),
shape = CircleShape,
color = CloserPalette.PurpleMist
color = MaterialTheme.colorScheme.primaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Text(
@ -599,7 +604,7 @@ private fun ThisOrThatMoodPicker(
TotMood.ALL -> "All"
},
style = MaterialTheme.typography.titleMedium,
color = CloserPalette.PurpleDeep,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold
)
}
@ -658,13 +663,13 @@ private fun ThisOrThatContent(
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = CloserPalette.PurpleMist
color = MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = "${currentIndex + 1} / $total",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = CloserPalette.PurpleDeep,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold
)
}
@ -698,13 +703,13 @@ private fun ThisOrThatContent(
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = CloserPalette.PurpleMist
color = MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = "This or That",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
style = MaterialTheme.typography.labelSmall,
color = CloserPalette.PurpleDeep,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold
)
}
@ -726,7 +731,7 @@ private fun ThisOrThatContent(
label = "A",
optionId = config.config.optionA.id,
pendingSelection = pendingSelection,
accentColor = CloserPalette.PurpleDeep,
isOptionA = true,
onSelect = onSelect
)
@ -739,7 +744,7 @@ private fun ThisOrThatContent(
label = "B",
optionId = config.config.optionB.id,
pendingSelection = pendingSelection,
accentColor = CloserPalette.PinkAccentDeep,
isOptionA = false,
onSelect = onSelect
)
} else {
@ -764,57 +769,58 @@ private fun ThisOrThatProgress(
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(999.dp))
.background(CloserPalette.PinkMist)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(progress.coerceIn(0f, 1f))
.clip(RoundedCornerShape(999.dp))
.background(CloserPalette.PurpleDeep)
.background(MaterialTheme.colorScheme.primary)
)
}
}
/**
* Soft, brand-native backdrop behind the prompt: a gentle glow plus two faint "paired cards" low in
* the frame (a private ritual for two). Theme-aware and very low-contrast it supports focus and
* never crosses the centered question text (the old two-circle + diagonal-line motif read like a
* technical diagram, especially in dark mode C-DARK-UI-001).
*/
@Composable
private fun ChoicePromptBackdrop(
modifier: Modifier = Modifier
) {
val dark = isCloserDarkTheme()
val cardA = MaterialTheme.colorScheme.primary.copy(alpha = if (dark) 0.16f else 0.12f)
val cardB = MaterialTheme.colorScheme.secondary.copy(alpha = if (dark) 0.16f else 0.12f)
val glow = MaterialTheme.colorScheme.primary.copy(alpha = if (dark) 0.12f else 0.08f)
Canvas(modifier = modifier) {
val minDimension = size.minDimension
val radius = minDimension * 0.16f
val left = Offset(size.width * 0.18f, size.height * 0.18f)
val right = Offset(size.width * 0.82f, size.height * 0.82f)
val strokeWidth = 3.dp.toPx()
drawLine(
color = CloserPalette.PurpleMist.copy(alpha = 0.9f),
start = left,
end = right,
strokeWidth = strokeWidth
)
drawCircle(
color = CloserPalette.PurpleGlow.copy(alpha = 0.58f),
radius = radius,
center = left
)
drawCircle(
color = CloserPalette.PinkSoft.copy(alpha = 0.76f),
radius = radius * 0.94f,
center = right
)
drawCircle(
color = CloserPalette.PurpleDeep.copy(alpha = 0.12f),
radius = radius * 0.56f,
center = left,
style = Stroke(width = strokeWidth)
)
drawCircle(
color = CloserPalette.PinkAccentDeep.copy(alpha = 0.1f),
radius = radius * 0.54f,
center = right,
style = Stroke(width = strokeWidth)
color = glow,
radius = size.minDimension * 0.62f,
center = Offset(size.width * 0.5f, size.height * 0.40f)
)
val cardW = size.width * 0.30f
val cardH = size.minDimension * 0.30f
val corner = CornerRadius(18.dp.toPx(), 18.dp.toPx())
val baseY = size.height * 0.82f
withTransform({ rotate(-11f, pivot = Offset(size.width * 0.30f, baseY)) }) {
drawRoundRect(
color = cardA,
topLeft = Offset(size.width * 0.30f - cardW / 2f, baseY - cardH / 2f),
size = Size(cardW, cardH),
cornerRadius = corner
)
}
withTransform({ rotate(11f, pivot = Offset(size.width * 0.70f, baseY)) }) {
drawRoundRect(
color = cardB,
topLeft = Offset(size.width * 0.70f - cardW / 2f, baseY - cardH / 2f),
size = Size(cardW, cardH),
cornerRadius = corner
)
}
}
}
@ -824,11 +830,19 @@ private fun OptionCard(
label: String,
optionId: String,
pendingSelection: String?,
accentColor: Color,
isOptionA: Boolean,
onSelect: (String) -> Unit
) {
val isSelected = pendingSelection == optionId
val isOtherSelected = pendingSelection != null && !isSelected
val cs = MaterialTheme.colorScheme
// A/B map to the brand primary / secondary roles, so every state derives from the active theme
// (light: rich purple/magenta on white; dark: light lavender/pink accents on a dark surface) —
// no fixed palette values that go dim or muddy in dark (C-DARK-UI-001).
val accent = if (isOptionA) cs.primary else cs.secondary
val selectedContainer = if (isOptionA) cs.primary else cs.secondary
val onSelectedContainer = if (isOptionA) cs.onPrimary else cs.onSecondary
val cardScale by animateFloatAsState(
targetValue = when {
@ -841,22 +855,31 @@ private fun OptionCard(
)
val background by animateColorAsState(
targetValue = when {
isSelected -> accentColor
isOtherSelected -> MaterialTheme.colorScheme.surface.copy(alpha = 0.45f)
else -> MaterialTheme.colorScheme.surface
isSelected -> selectedContainer
isOtherSelected -> cs.surface.copy(alpha = 0.55f)
else -> closerCardColor(alpha = 0.95f)
},
animationSpec = tween(180),
label = "bg_$optionId"
)
val contentColor by animateColorAsState(
targetValue = when {
isSelected -> Color.White
isOtherSelected -> Color(0xFFCCC0D5)
else -> accentColor
isSelected -> onSelectedContainer
isOtherSelected -> cs.onSurfaceVariant
else -> cs.onSurface
},
animationSpec = tween(180),
label = "fg_$optionId"
)
val borderColor by animateColorAsState(
targetValue = when {
isSelected -> Color.Transparent
isOtherSelected -> cs.outlineVariant
else -> accent.copy(alpha = 0.55f)
},
animationSpec = tween(180),
label = "border_$optionId"
)
Card(
onClick = { if (pendingSelection == null) onSelect(optionId) },
@ -866,7 +889,8 @@ private fun OptionCard(
.scale(cardScale),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = background),
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 10.dp else 4.dp)
border = BorderStroke(1.5.dp, borderColor),
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 10.dp else 3.dp)
) {
Row(
modifier = Modifier
@ -877,7 +901,7 @@ private fun OptionCard(
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = if (isSelected) Color.White.copy(alpha = 0.22f) else accentColor.copy(alpha = 0.12f),
color = if (isSelected) Color.White.copy(alpha = 0.22f) else accent.copy(alpha = 0.16f),
modifier = Modifier.size(32.dp)
) {
Box(contentAlignment = Alignment.Center) {
@ -885,7 +909,7 @@ private fun OptionCard(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = contentColor
color = if (isSelected) onSelectedContainer else accent
)
}
}
@ -918,14 +942,14 @@ private fun VersusBadge(
Surface(
modifier = modifier.scale(pulse),
shape = RoundedCornerShape(999.dp),
color = CloserPalette.PurpleMist,
color = MaterialTheme.colorScheme.primaryContainer,
shadowElevation = 4.dp
) {
Text(
text = "OR",
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
color = CloserPalette.PurpleDeep,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold
)
}
@ -1228,20 +1252,22 @@ private fun ErrorState(message: String, onBack: () -> Unit, onRetry: (() -> Unit
private fun TotLengthChips(selected: SessionLength, onSelect: (SessionLength) -> Unit) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SessionLength.values().forEach { len ->
val selectedChip = len == selected
Surface(
onClick = { onSelect(len) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
color = if (len == selected) CloserPalette.PurpleDeep else Color.Transparent,
border = if (len != selected)
BorderStroke(1.dp, CloserPalette.PurpleDeep.copy(alpha = 0.4f))
color = if (selectedChip) MaterialTheme.colorScheme.primary else Color.Transparent,
border = if (!selectedChip)
BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
else null
) {
Text(
text = len.label,
modifier = Modifier.padding(vertical = 10.dp),
style = MaterialTheme.typography.labelMedium,
color = if (len == selected) Color.White else CloserPalette.PurpleDeep,
color = if (selectedChip) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
@ -1367,9 +1393,8 @@ fun ThisOrThatReplayScreen(
// ── Preview ───────────────────────────────────────────────────────────────────
@Preview
@Composable
private fun ThisOrThatContentPreview() {
private fun ThisOrThatContentPreviewBody(pendingSelection: String?) {
val config = ThisOrThatAnswerConfigImpl(
config = ThisOrThatAnswerConfig(
optionA = ChoiceOption(id = "game_night", text = "Game night"),
@ -1382,15 +1407,27 @@ private fun ThisOrThatContentPreview() {
category = "fun",
answerConfig = config
)
Box(Modifier.background(Color(0xFFFFFBFE))) {
Box(Modifier.background(closerBackgroundBrush())) {
ThisOrThatContent(
question = question,
config = config,
currentIndex = 2,
total = 10,
pendingSelection = null,
pendingSelection = pendingSelection,
onSelect = {},
onBack = {}
)
}
}
@Preview(name = "ToT • light")
@Composable
private fun ThisOrThatContentPreviewLight() {
CloserTheme(darkTheme = false) { ThisOrThatContentPreviewBody(pendingSelection = null) }
}
@Preview(name = "ToT • dark + selected")
@Composable
private fun ThisOrThatContentPreviewDark() {
CloserTheme(darkTheme = true) { ThisOrThatContentPreviewBody(pendingSelection = "game_night") }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -1144,6 +1144,21 @@ SCRIPTS.md
These are bugs that cost real debugging time and are easy to re-introduce if you don't know they existed. Before changing the relevant area, re-read the linked fix commit and the QA report entry. Format: **ID** — what it was — where it lives now.
### C-DARK-UI-001 — game surfaces must use theme tokens, not fixed palette darks
**Symptom**: This-or-That active gameplay was off-brand and weakly legible in dark mode — option body text and mood/duration chips used fixed `CloserPalette.PurpleDeep`/`PinkAccentDeep` (dark values) on a dark surface, and `ChoicePromptBackdrop` drew a diagonal line + two circles that read like a technical diagram crossing the prompt.
**Fix (R13)**: `ThisOrThatScreen.kt` — A/B options map to `MaterialTheme.colorScheme.primary`/`secondary` (light lavender/pink in dark, rich purple/magenta in light) with high-contrast `onSurface` body text + visible accent `BorderStroke` + a filled `onPrimary`/`onSecondary` selected state; `VersusBadge`, progress, the `N/total`+title pills, the mood number-circle, and `TotLengthChips` all read from `colorScheme`; `ChoicePromptBackdrop` redrawn as a soft glow + two faint paired-card silhouettes (low alpha, never crossing the prompt). Light+dark previews added.
**Re-introduction risk**: any in-game surface that hardcodes `CloserPalette.*` dark values instead of `MaterialTheme.colorScheme` will go dim/muddy in one theme. Use the theme tokens (or the `closerSoft*`/`isCloserDarkTheme()` helpers); verify BOTH themes.
### C-ART-EDGE-002 — opaque hero illustrations need feathering; transparent ones don't
**Symptom**: hero illustrations rendered via direct `painterResource(R.drawable.illustration_*)` showed a hard bright rounded-rect block on dark (e.g. Today "Weekend Side Quest"). The R11 feather (C-ART-EDGE-001) only covered `BrandIllustration`/`EmptyState`.
**Fix (R13)**: route **opaque** (RGB / no-alpha) hero tiles through `BrandIllustration(tile = true)` (gains `featherEdges()` + the `-night` theme variant). Confirmed opacity with `identify -format '%[opaque]'`: `illustration_daily_question`, `couple_paywall`, `couple_subscription`, `couple_onboarding`, `partner_activation`, `tonight_partner_prompt`, `couple_invite`, `together_empty` are opaque → feathered. `spin_wheel`, `streak_milestone`, `reveal_celebration` are transparent/celebration → left as direct `Image`/`painterResource` (they float; feathering them is wrong).
**Re-introduction risk**: adding a new opaque rounded-rect illustration via raw `Image(painterResource(...))` will show a hard edge on dark. Check the PNG's alpha; if opaque, use `BrandIllustration(tile=true)`.
### Premium-unlock modal — one-time-gate pattern (driven off CouplePremiumChecker, not the push)
**What**: `ui/components/PremiumUnlockOverlay.kt` shows the one-time "Premium unlocked" celebration to BOTH partners when couple-shared Premium activates. It is driven by `CouplePremiumChecker.isPremium()` (which OR-combines both partners) — NOT by the `subscription_entitlement_changed` push — so it fires for the purchaser and the partner wherever they are. Hosted at the `AppNavigation` root next to `MessageBubbleOverlay`.
**Gate**: persisted `premiumUnlockCelebrated` flag on `SettingsRepository`/`SettingsDataStore`; set on dismiss, auto-reset when Premium lapses (so a re-activation celebrates again). Mirrors `lastCelebratedStreakMilestone`.
**Re-introduction risk**: gating the modal on the push route alone would miss the purchaser (and the partner if FCM is flaky on emulators). Keep it observing `CouplePremiumChecker`. Don't forget to reset the flag on lapse, or a second subscription period never re-celebrates.
### F-RACE-001 — duplicate game-start push on rapid partner update
**Symptom**: both partners got a "game started" push when only one started it. Caused by diffing `status` field on game session update trigger; two near-simultaneous updates both saw the diff.
**Fix**: replaced status-diff with idempotent flag-claim on a `notificationsSent` map (commit `6e79cd9`). See [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim).