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:
parent
4eed0a8115
commit
c31eea2549
|
|
@ -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 | ✅ |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue