diff --git a/ClaudeBrandingReview.md b/ClaudeBrandingReview.md index 87283a8b..7cec8c33 100644 --- a/ClaudeBrandingReview.md +++ b/ClaudeBrandingReview.md @@ -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 | ✅ | diff --git a/ClaudeQACoverage.md b/ClaudeQACoverage.md index f9664513..88c30d1b 100644 --- a/ClaudeQACoverage.md +++ b/ClaudeQACoverage.md @@ -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 P0–P2. 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 A–J — FLAWLESS, 0 open P0–P3.** 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) ~42–45dp 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 A–J, FLAWLESS (0 open P0–P3): 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 A–J run + fix phase, FLAWLESS (0 open P0–P2): 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 P0–P2): 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 A–J + 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 D1–D7 clean; perf/a11y no regression. 0 open P0–P2 (5×P2 pending 1 confirm). diff --git a/ClaudeReport.md b/ClaudeReport.md index c97694bd..83dbce76 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -1,5 +1,7 @@ # Claude QA Report — Full-App QA (living report) +> **Verdict (2026-06-27): R13 = fixed the entire open backlog + full fresh A–J — FLAWLESS (0 open P0–P3).** 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 A–J 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 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.** @@ -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 A–J — FLAWLESS (0 open P0–P3).** 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). **A–J:** 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 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).` @@ -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 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 @@ -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 **~42–45dp wide** (48dp tall) — single-axis marginal miss of the 48dp target; fully operable. Most controls are 48dp. | Pass J: uiautomator bounds on conversation → 2–3 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 **~42–45dp wide** (48dp tall) — single-axis marginal miss of the 48dp target; fully operable. Most controls are 48dp. | Pass J: uiautomator bounds on conversation → 2–3 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 A–J, FLAWLESS (0 open P0–P3).** 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). A–J: 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 A–J run + fix phase, FLAWLESS (0 open P0–P2).** 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 diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index b535ad3c..2afaf85e 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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() } } diff --git a/app/src/main/java/app/closer/data/local/SettingsDataStore.kt b/app/src/main/java/app/closer/data/local/SettingsDataStore.kt index ad367d94..7f118ca5 100644 --- a/app/src/main/java/app/closer/data/local/SettingsDataStore.kt +++ b/app/src/main/java/app/closer/data/local/SettingsDataStore.kt @@ -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 = 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 {} } diff --git a/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt b/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt index f1d1bebd..e4d3c453 100644 --- a/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt @@ -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) } diff --git a/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt b/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt index 90251adf..f4f25b94 100644 --- a/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt +++ b/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt @@ -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) ) diff --git a/app/src/main/java/app/closer/ui/components/OutcomeCheckInDialog.kt b/app/src/main/java/app/closer/ui/components/OutcomeCheckInDialog.kt index 22217f2b..abdf4934 100644 --- a/app/src/main/java/app/closer/ui/components/OutcomeCheckInDialog.kt +++ b/app/src/main/java/app/closer/ui/components/OutcomeCheckInDialog.kt @@ -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(), diff --git a/app/src/main/java/app/closer/ui/components/PremiumUnlockOverlay.kt b/app/src/main/java/app/closer/ui/components/PremiumUnlockOverlay.kt new file mode 100644 index 00000000..e5aa21ff --- /dev/null +++ b/app/src/main/java/app/closer/ui/components/PremiumUnlockOverlay.kt @@ -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 = _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 + ) + } + } + } + } +} diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 18d3f617..3f65bfff 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt index faf5ef98..72351d33 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt @@ -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 { diff --git a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt index f7e21a9e..ab6fa3dc 100644 --- a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt +++ b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt @@ -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)) { diff --git a/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt index 32be15bd..b8f378e4 100644 --- a/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/pairing/PairPromptScreen.kt b/app/src/main/java/app/closer/ui/pairing/PairPromptScreen.kt index 7fd69e85..57185477 100644 --- a/app/src/main/java/app/closer/ui/pairing/PairPromptScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/PairPromptScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt index 9bfc31c1..a32a438b 100644 --- a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt +++ b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt index 4baffdcb..db199703 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt @@ -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 { diff --git a/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt b/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt index ba1fad4e..a989a0be 100644 --- a/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt +++ b/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt b/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt index d0b91e53..e8d9dc1d 100644 --- a/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 474a8505..c4d16e6a 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -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") } +} diff --git a/app/src/main/res/drawable-nodpi/illustration_premium_unlock.png b/app/src/main/res/drawable-nodpi/illustration_premium_unlock.png new file mode 100644 index 00000000..cdbe90ae Binary files /dev/null and b/app/src/main/res/drawable-nodpi/illustration_premium_unlock.png differ diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 17738455..a457114e 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -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).