fix(qa): R10 fix phase — 5 P2 bugs fixed (C-HOME-001, C-NAV-002, C-NAV-003, C-PW-001, C-SEC-001)

This commit is contained in:
null 2026-06-27 10:34:26 -05:00
parent 32b5b560a2
commit 9c84c36443
11 changed files with 102 additions and 55 deletions

View File

@ -11,6 +11,13 @@ the existing artwork (`docs/brand/visual-identity.md` + `docs/brand/asset-system
--- ---
## R10 brand walk (2026-06-26) — existing art integration clean, 0 defects
R10 visual sweep doubled as the Pass-H existing-art integration check: Today (paired-books daily-question art),
Paywall (couple illustration), Security (padlock), Memory Lane / Date / Bucket-List empties, Home cards — **all render
on-brand, in-context, both themes, no clipping/placeholder/off-brand issues** (any defect would be a `ClaudeReport.md`
bug; none found). The new game-alert surfaces (`GamePromptBanner`, `GameWaitingHeroCard`) use the brand purple gradient
+ PlayArrow glyph and read as intentional action banners — no illustration warranted. No new art to add this round.
## ✅ WIRED INTO ANDROID (2026-06-26 art drop) — all 11 generated illustrations live ## ✅ WIRED INTO ANDROID (2026-06-26 art drop) — all 11 generated illustrations live
The generated art (source in gitignored `docs/brand/generated-art/`, copied full-res to The generated art (source in gitignored `docs/brand/generated-art/`, copied full-res to
`app/src/main/res/drawable-nodpi/`) is now wired into the app via the shared `EmptyState` `app/src/main/res/drawable-nodpi/`) is now wired into the app via the shared `EmptyState`

View File

@ -1,7 +1,7 @@
# Claude QA Coverage Matrix # Claude QA Coverage Matrix
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`. > **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
> Build `23dd6a7` (+ `ab29f6b` outcomes fix). Position + verdict: see `ClaudeReport.md` run-state. **Verdict: AJ covered, R9 clean confirmation round (0 new). Open: J-OBS (P3) only. I-001/I-002 fixed+confirmed.** > Build `32b5b56` + R10 working-tree fixes (5×P2). Position + verdict: see `ClaudeReport.md` run-state. **Verdict: R10 FULL run AJ + fix phase COMPLETE. 0 open P0P2; 1 P3 (J-OBS). 5 P2 found+fixed+verified-live this round (pending 1 confirm). E-GAME-002 confirmed+pruned. Security D1D7 clean.**
> Hygiene: this is a *current-status* matrix, not a per-round changelog — `fail→id` flips to `pass` once a fix is > Hygiene: this is a *current-status* matrix, not a per-round changelog — `fail→id` flips to `pass` once a fix is
> confirmed (ID archived below); finished rounds collapse to the history line. (See Report hygiene in `ClaudeQAPlan.md`.) > confirmed (ID archived below); finished rounds collapse to the history line. (See Report hygiene in `ClaudeQAPlan.md`.)
@ -10,14 +10,14 @@
|---|---|---| |---|---|---|
| A — Couple-shared premium | all gated features × neither/partner/self | ✅ pass | | A — Couple-shared premium | all gated features × neither/partner/self | ✅ pass |
| B — Games lifecycle | all 7 games played full, 2-device, real user-nav | ✅ pass | | B — Games lifecycle | all 7 games played full, 2-device, real user-nav | ✅ pass |
| C — Visual (light+dark) | ~14 core screen-types both themes | ✅ pass · deep/list screens **deferred** | | C — Visual (light+dark) | R10 re-sweep: Messages/conv/thread, Today, Paywall, Wheel History, Activity, Home, wheel back-stack | ⚠️ 5 P2 open (C-HOME-001 dup card · C-NAV-002 wheel back-stack · C-NAV-003 dbl header · C-PW-001 dark paywall · C-SEC-001 recovery) |
| D — Security & encryption | D1 at-rest · D2 rules · D3 live raw-API · D4D6 | ✅ clean | | D — Security & encryption | R10: D1 at-rest · D2 rules (incl secure-subdoc gate) · D3 live raw-API · D4 recovery · D5/D6/D7 | ✅ clean (C-SEC-001 = UI wrong-store, not crypto) |
| E — Notifications | chat + game start/finish/results live, both-client + suppression | ✅ pass · full fg/bg/killed matrix **partial** | | E — Notifications | R10 live: E-GAME-002 confirmed (start push+banner+Join), finish/answer/reveal pushes | ✅ pass · E-GAME-002 pruned · full fg/bg/killed matrix **partial** |
| F — Resilience | concurrency · offline · lifecycle · process-death · time | ✅ pass | | F — Resilience | R10: concurrency double-start→1 session · process-death→clean+FCM re-register · offline(R9) | ✅ pass · time-travel + deletion-cascade deferred |
| G — Account creation / fake-account | sign-up · validation · duplicate · invite-abuse | ✅ pass | | 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 | consumer brand walk → prompts | see `ClaudeBrandingReview.md` | | H — Branding & artwork | R10: existing-art integration clean (0 defects), new game surfaces on-brand | ✅ see `ClaudeBrandingReview.md` |
| I — Performance & route efficiency | cold-start, jank (core/conversation/hub), leak proxy, caching | ✅ done · **I-001 (P1)** outcomes read denied | | I — Performance & route efficiency | R10: core-tabs jank 5.53% (R8 6.3%), 90th 32ms, leak proxy bounded | ✅ no regression |
| J — Accessibility | font scale 2.0, semantics, targets, reduce-motion | ✅ done · J-OBS (P3) ~4245dp targets | | J — Accessibility | R10: font scale 2.0 reflows (new hero ok), reduce-motion×7 | ✅ done · J-OBS (P3) ~4245dp targets |
**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**. **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**.
@ -53,6 +53,7 @@ Note: exit each game via "Back to Play" between games so the session closes (B-0
## Pass C — Visual (light + dark), all ~50 routes ## Pass C — Visual (light + dark), all ~50 routes
~14 screen-types swept Dark (5554) + several Light (5556): all render clean, readable, no FATAL, no dark-mode contrast issues; **0 `enc:v1:` leaked to conversation UI**. Covered: Home, Play hub, all 7 game screens (setup/play/reveal), Paywall, Settings (+Subscription +Appearance), Today/daily-question (+answer detail), Messages inbox, Conversation (image+voice+text+reaction). Back-stack clean (deep→hub→Home→launcher, no double-back). ~14 screen-types swept Dark (5554) + several Light (5556): all render clean, readable, no FATAL, no dark-mode contrast issues; **0 `enc:v1:` leaked to conversation UI**. Covered: Home, Play hub, all 7 game screens (setup/play/reveal), Paywall, Settings (+Subscription +Appearance), Today/daily-question (+answer detail), Messages inbox, Conversation (image+voice+text+reaction). Back-stack clean (deep→hub→Home→launcher, no double-back).
- **R10 re-sweep (both themes where relevant):** Messages inbox ✅ (dark+light: conversations, avatars, unread dot, previews decrypted, no `enc:` leak), Conversation ✅ (image/voice/text/reaction/read-receipt/date-sep, E2E lock glyphs, correct attribution both dirs), per-question Discussion thread ✅, Today/daily-question ✅ (dark+light, paired-books art on-brand), Activity/Together ✅ (dark). **5 P2 found:** C-HOME-001 (Home shows top pending action twice — `primaryAction` hero + `buildPendingActions` row), C-NAV-002 (wheel results→BACK re-enters finished play screen, no popUpTo), C-NAV-003 (Wheel History/Past Games + PartnerHome double app-bar — route in `shellBackRoutes` while screen owns a TopAppBar; C-CC-001 class), C-PW-001 (dark paywall "What's included" pills light-on-light, `BenefitPill` onSurface text), C-SEC-001 (accepter recovery copy). Premium-locked Wheel History state renders. Date Builder · Question Packs(gated→paywall) · Answer Reveal sealed = token-consistent, R9-clean.
- **R9 deferred sweep — 0 new issues:** Answer History, Together/Activity, Bucket List (empty state + FAB), Date Match deck, Date Matches all render cleanly in **dark** (good contrast, no clipping, no FATAL); Privacy & Terms + Home confirm **light** parity (shared Material3 tokens). Remaining standard list/detail (Wheel History · Date Builder · Past Games · Answer Reveal sealed · Question Packs[gated→paywall]) are token-consistent with the above; fresh-account auth/onboarding visual covered R3/R5. No C findings. - **R9 deferred sweep — 0 new issues:** Answer History, Together/Activity, Bucket List (empty state + FAB), Date Match deck, Date Matches all render cleanly in **dark** (good contrast, no clipping, no FATAL); Privacy & Terms + Home confirm **light** parity (shared Material3 tokens). Remaining standard list/detail (Wheel History · Date Builder · Past Games · Answer Reveal sealed · Question Packs[gated→paywall]) are token-consistent with the above; fresh-account auth/onboarding visual covered R3/R5. No C findings.
## Pass D — Security & encryption (D1D6) — clean, no P0/P1 ## Pass D — Security & encryption (D1D6) — clean, no P0/P1
@ -111,6 +112,7 @@ Route smoke-test checklist (re-runnable: `dumpsys gfxinfo closer.app reset` →
--- ---
## Round history (one line each) ## Round history (one line each)
- **R10** — FULL run AJ + fix phase: 5 P2 found+fixed+verified-live (C-HOME-001 dup card · C-NAV-002 wheel back-stack · C-NAV-003 dup app bar · C-PW-001 dark paywall · C-SEC-001 recovery wrong-store); E-GAME-002 confirmed live (start push+banner+Join) & pruned; concurrency double-start→1 session; security D1D7 clean; perf/a11y no regression. 0 open P0P2 (5×P2 pending 1 confirm).
- **R7** — security/concurrency deep dive (multi-angle): cornerstone clean; F-RACE-001 found+fixed+verified. 0 new open. - **R7** — security/concurrency deep dive (multi-angle): cornerstone clean; F-RACE-001 found+fixed+verified. 0 new open.
- **R6** — branding drop + Future.md backlog regression: 0 new open. - **R6** — branding drop + Future.md backlog regression: 0 new open.
- **R5** — Cloud Functions deployed (E-OBS/E-003) + new Pass G clean: 0 open. - **R5** — Cloud Functions deployed (E-OBS/E-003) + new Pass G clean: 0 open.

View File

@ -1,12 +1,12 @@
# Claude QA Report — Full-App QA (living report) # Claude QA Report — Full-App QA (living report)
> **Verdict (2026-06-26): 0 open P0P2 (1 P3 J-OBS). Daily-question couple-key reveal QA'd live this session — all PASS, no new bugs. (Reveal feature now committed: HEAD `e6a8dee`.)** > **Verdict (2026-06-26): R10 FULL ClaudeQAPlan run COMPLETE (AJ + fix phase). 0 open P0P2; 1 P3 (J-OBS). Found 5 P2 this round (Home dup card, wheel back-stack, duplicate app bar, dark paywall contrast, recovery-phrase wrong store) — ALL fixed + verified live + regression-clean (0 FATAL, content still `enc:v1:`). E-GAME-002 confirmed live + pruned. Security cornerstone clean (D1D7). Fixes in working tree — user commits.**
> >
> This report shows **current state only**. Fixed issues live here for **one** confirmation round, then they're pruned > This report shows **current state only**. Fixed issues live here for **one** confirmation round, then they're pruned
> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`. > to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
## Run-state (current) ## Run-state (current)
`R10 (2026-06-26) full ClaudeQAPlan run | Pass A ✅ Pass B ✅ | Pass C in progress | Fixed **E-GAME-002 (P1, user-reported)** game-start push + foreground deep-link | 1 open P2 (C-SEC-001) + 1 P3 J-OBS | Sam premium = ON | NEXT ACTION: continue Pass C families (Messages, Today/reveal, Date/BucketList, Wheel history, AnswerHistory, YourProgress, Paywall, auth/onboarding, light parity); confirm E-GAME-002 next round then prune. Then DJ.` `R10 (2026-06-26) full ClaudeQAPlan run COMPLETE | A ✅ B ✅ C ✅ D ✅ E ✅ F ✅ G ✅ H ✅ I ✅ J ✅ | Fix phase ✅ — 5×P2 fixed+verified live (C-HOME-001 · C-NAV-002 · C-NAV-003 · C-PW-001 · C-SEC-001) | E-GAME-002 confirmed+pruned | 0 open P0P2; 1 P3 (J-OBS) | Baseline: both FREE, 0 active sessions; build installed both emulators | NEXT (R11 = next session): brief confirmation round → re-verify the 5 P2 fixes hold + regression sweep → prune the 5 Fixed rows → flawless. Optional: J-OBS (P3) touch targets. App fixes in working tree (user commits).`
- **Uncommitted (user commits):** `functions/src/games/onGameSessionUpdate.ts` (DEPLOYED live), `app/.../MainActivity.kt` + `app/.../notifications/PartnerNotificationManager.kt` (E-GAME-002). **+ Foreground game-alert feature (this session, app rebuilt+installed both):** NEW `notifications/GamePromptController.kt`, `ui/components/GamePromptBanner.kt`; edited `core/navigation/AppNavigation.kt`, `core/notifications/AppMessagingService.kt`, `ui/home/HomeScreen.kt`, `ui/home/HomeViewModel.kt`, `ui/wheel/WheelSessionViewModel.kt`. Note: reinstalling the debug APK can leave a stale FCM token until re-register on next launch. - **Uncommitted (user commits):** `functions/src/games/onGameSessionUpdate.ts` (DEPLOYED live), `app/.../MainActivity.kt` + `app/.../notifications/PartnerNotificationManager.kt` (E-GAME-002). **+ Foreground game-alert feature (this session, app rebuilt+installed both):** NEW `notifications/GamePromptController.kt`, `ui/components/GamePromptBanner.kt`; edited `core/navigation/AppNavigation.kt`, `core/notifications/AppMessagingService.kt`, `ui/home/HomeScreen.kt`, `ui/home/HomeViewModel.kt`, `ui/wheel/WheelSessionViewModel.kt`. Note: reinstalling the debug APK can leave a stale FCM token until re-register on next launch.
- **Foreground "partner started a game" alert + bold Game Waiting card (R10, user-requested) — DONE+VERIFIED.** When the app is OPEN and a partner starts a game, a prominent **in-app top banner** ("<partner> started <Game>" + Join) slides in (mirrors chat's in-app surface) instead of the easy-to-miss system notification; **Join → joins the game**. Verified live for all 4 session games: banner name correct (Spin the Wheel / This or That / How Well Do You Know Me / Desire Sync); Join → joins wheel (ToT shows graceful "QA is playing a Wheel game" when types mismatch); **suppressed** when already on that game's screen (added `ActiveGameSessionMonitor.enter/leave` to `WheelSessionViewModel` — the others already had it). Home **"Game waiting"** card redesigned as a **bold purple-gradient hero** (glyph + game name + "Join the game"), promoted to top of "Waiting for you", verified **both themes** → tap **joins the specific game** (not the Play-hub fallback). FCM transport on the emulators is flaky (FcmRetry); the banner was exercised via a data-only high-priority send to the partner token (faithful to the deployed payload). - **Foreground "partner started a game" alert + bold Game Waiting card (R10, user-requested) — DONE+VERIFIED.** When the app is OPEN and a partner starts a game, a prominent **in-app top banner** ("<partner> started <Game>" + Join) slides in (mirrors chat's in-app surface) instead of the easy-to-miss system notification; **Join → joins the game**. Verified live for all 4 session games: banner name correct (Spin the Wheel / This or That / How Well Do You Know Me / Desire Sync); Join → joins wheel (ToT shows graceful "QA is playing a Wheel game" when types mismatch); **suppressed** when already on that game's screen (added `ActiveGameSessionMonitor.enter/leave` to `WheelSessionViewModel` — the others already had it). Home **"Game waiting"** card redesigned as a **bold purple-gradient hero** (glyph + game name + "Join the game"), promoted to top of "Waiting for you", verified **both themes** → tap **joins the specific game** (not the Play-hub fallback). FCM transport on the emulators is flaky (FcmRetry); the banner was exercised via a data-only high-priority send to the partner token (faithful to the deployed payload).
- **Pass C progress (R10):** **Settings family ✅** (dark + Security on light): Settings list, Subscription, Security, Delete account, Notifications all render clean both-relevant-themes; **4 illustrations confirmed in-context** (Security padlock, Delete-account doorway, Quiet-hours moon, + Subscription); back-stack OK (Security/Delete/Notif → BACK → Settings). **Found C-SEC-001 (P2)** — accepter's Recovery phrase disabled + wrong "invite your partner" copy (see Open issues). **Wheel back-stack RE-CHECKED = not a trap:** live wheel session → BACK → spin/setup → BACK → Play hub (2 backs, no dead-end); earlier "stuck" was automation cycling. Leaving mid-wheel leaves a resumable abandoned active session (normal; cleaned). Home ✅ both themes (stale game card gone). - **Pass C progress (R10):** **Settings family ✅** (dark + Security on light): Settings list, Subscription, Security, Delete account, Notifications all render clean both-relevant-themes; **4 illustrations confirmed in-context** (Security padlock, Delete-account doorway, Quiet-hours moon, + Subscription); back-stack OK (Security/Delete/Notif → BACK → Settings). **Found C-SEC-001 (P2)** — accepter's Recovery phrase disabled + wrong "invite your partner" copy (see Open issues). **Wheel back-stack RE-CHECKED = not a trap:** live wheel session → BACK → spin/setup → BACK → Play hub (2 backs, no dead-end); earlier "stuck" was automation cycling. Leaving mid-wheel leaves a resumable abandoned active session (normal; cleaned). Home ✅ both themes (stale game card gone).
@ -28,19 +28,29 @@
| Severity | Open | Fixed (pending 1 confirm) | | Severity | Open | Fixed (pending 1 confirm) |
|---|---|---| |---|---|---|
| P0 | 0 | 0 | | P0 | 0 | 0 |
| P1 | 0 | **1** | | P1 | 0 | 0 |
| P2 | **1** | 0 | | P2 | **0** | **5** |
| P3 | **1** | 0 | | P3 | **1** | 0 |
## Open issues ## Issues — R10 (5×P2 fixed + verified live this round, pending 1 confirm; J-OBS P3 open)
> Each P2 below was found in R10's report-only passes, then fixed + verified live in the R10 fix phase (build
> succeeded, both emulators reinstalled, 0 FATAL, content still `enc:v1:` at rest). Per hygiene they survive **one**
> confirmation round, then prune. **Fix commits are in the working tree (user commits).** Quick fix summary:
> C-HOME-001 `buildPendingActions().filterNot{it.target==primary?.target}` · C-NAV-002 `popUpTo(WHEEL_SESSION){inclusive}` on session→complete ·
> C-NAV-003 removed WHEEL_HISTORY/GAME_HISTORY/PARTNER_HOME from `shellBackRoutes` · C-PW-001 `BenefitPill` text→`PurpleDeep` ·
> C-SEC-001 `SecurityViewModel` reads `encryptionManager.recoveryPhrase(coupleId)` (CoupleKeyStore) + corrected copy.
| ID | Sev | Area | Description | Repro | Suggested fix | Status | | ID | Sev | Area | Description | Repro | Suggested fix | Status |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| E-GAME-002 | P1 | Notifications / games (user-reported, R10) | **Two bugs in the game-start notification flow** (started Spin-the-Wheel; partner on Settings got nothing). **(a) Push not sent (intermittent):** the atomic session-start (F-RACE-001, `runTransaction`) writes the session doc **and** the `sessions/_active` pointer in one transaction → `onGameSessionUpdate` (onWrite) fires twice and transactional writes deliver `change.before == change.after` ("Snapshot has no readTime") → the `inactive→active` edge was missed → no `partner_started_game`. **(b) Foreground tap dead:** foreground-posted notifications use a **Uri** PendingIntent, but the NavHost has **no navDeepLink for game/challenge/date/capsule routes** → the tap fell through to Home (`deepLinkRouteFromIntent` bails on any Uri). | QA start wheel; Sam on Settings → no push (logs: 20:24 fired, no notifyPartner). Foreground tap → stayed/Home. | **(a)** detect start/finish by current `status` + idempotent `startNotifiedAt`/`finishNotifiedAt` flag claimed in a tx (exactly-once, robust to before/after); skip `sessionId==='_active'`. **(b)** carry the resolved route as an `app_route` intent extra; `deepLinkRouteFromIntent` prefers it (works for all routes, no per-route navDeepLink needed). | **Fixed+verified (deployed funcs + app rebuilt/installed). Start push fires for ALL 4 session games — `startNotifiedAt` SET (set only inside the claim tx right before notifyPartner): this_or_that ✓, how_well ✓, desire_sync ✓, wheel ✓ (+ wheel device delivery "Your partner started a game…"). **Tap-opens-destination verified via the real system-tray tap path (extras→deepLinkRouteFromIntent→navigate):** start push tap **opens the game** — wheel (joins 1/10) + this_or_that (joins 1/10); finish push tap **opens the results** — wheel → real "Here's how you each answered" replay (wheel_complete/{id}) for a fully-played session. Routing is gameType-data-driven (gameRouteForType/gameResultsRouteFor), so how_well/desire_sync follow the same pattern (valid replay routes). Also: foreground/cold app_route tap joins wheel. Finish branch hardened identically (partner_finished_game fired on completion). N/A for non-session games (Connection Challenges/Memory Lane/Date Match use challenge_day_ready/capsule_unlocked/date_match, not partner_started_game). Functions deployed live; app + funcs source uncommitted (user commits).** | | C-NAV-003 | P2 | Nav / duplicate header (Pass C, R10) | **Two stacked app bars (double back arrow) on Wheel History / Past Games** — regression of the C-CC-001 class. `WHEEL_HISTORY` + `GAME_HISTORY` are in `shellBackRoutes` (AppNavigation.kt:615-616) so the shell draws a "Wheel History" back bar, but `GameHistoryScreen` (`WheelHistoryScreen.kt:69-72`) renders its **own** `Scaffold`+`TopAppBar("Past Games")` → two title bars + two back arrows stacked. A grep of every `shellBackRoutes` screen shows exactly two that own a `TopAppBar`: GameHistory (verified live) and **PartnerHome** (`PartnerHomeScreen.kt:228`, `PARTNER_HOME` in shellBackRoutes:594, reachable via Home StreakCard `onPartner`) — same defect, code-confirmed. | 5554: Play → Spin the Wheel → History → screen shows "← Wheel History" bar over "← Past Games" bar over "Past games" content. | Remove `WHEEL_HISTORY`, `GAME_HISTORY`, `PARTNER_HOME` from `shellBackRoutes` (the screens own their headers) — exactly the fix applied to CONNECTION_CHALLENGES for C-CC-001 (see comment at AppNavigation.kt:609). | **Fixed + verified live R10 (working tree; user commits)** |
| C-SEC-001 | P2 | Security / recovery (Pass C, R10) | The **accepter** partner's Security screen shows the Recovery phrase **disabled** with copy *"Recovery phrase becomes available after you invite your partner"* — but they're **already paired** (they accepted an invite, never "invite"). The **inviter** (Sam) sees an active Recovery phrase; the **accepter** (QA) cannot access one. Misleading copy for a paired user + surfaces a recovery asymmetry (only the inviter holds the phrase). | 5554 (QA, accepter): Settings → Security → Recovery phrase row greyed + that copy. 5556 (Sam, inviter): same screen → Recovery phrase active. | Fix the accepter copy (e.g. "Your partner holds your couple's recovery phrase" / explain shared-recovery); confirm in **D4** whether the accepter can recover the couple key at all (if not, that's a deeper gap). | **Open (P2)** | | C-PW-001 | P2 | Paywall / dark-mode contrast (Pass C, R10) | **Paywall "What's included" benefit pills are near-invisible in dark theme.** `BenefitPill` (`PaywallScreen.kt:246`) draws a hardcoded **light** background `CloserPalette.PurpleMist` but sets text `color = MaterialTheme.colorScheme.onSurface`, which is near-white on dark → light-on-light, text barely legible (the `PurpleDeep` checkmark stays visible). Light theme is fine. Same class as the fixed C-DS-001. | 5554 (dark): Play → Desire Sync (both free) → Paywall → "What's included" list rows show white pills with unreadable labels (confirmed via crop; text present in hierarchy). 5556 (light): same rows legible. | Give `BenefitPill` text a fixed dark brand color (e.g. `CloserPalette.PurpleDeep` / `0xFF56306F`, matching the checkmark) since the pill background is always light, OR make the pill background theme-adaptive. | **Fixed + verified live R10 (working tree; user commits)** |
| C-NAV-002 | P2 | Nav / back-stack (Pass C, R10) | **Finishing Spin-the-Wheel → results → system BACK re-enters the completed Wheel Session play screen** (shows "10/10, Finish/Skip/End session"), then BACK again → Wheel hub. The session→complete nav uses plain `navigateRoute` (`else → navController.navigate`, no `popUpTo`), so the finished play screen stays on the back stack. This is the "WATCH — wheel back-stack" item flagged in B; now deliberately reproduced on 5556 (not an automation artifact). | 5556: play a wheel to completion → auto-lands on Complete/results → BACK → lands inside the finished Wheel Session screen. | When navigating WHEEL_SESSION→WHEEL_COMPLETE, `popUpTo(WHEEL_SESSION){inclusive=true}` (or pop in WheelSessionScreen on navigateTo) so BACK from results returns to the wheel hub/Play, not the finished play screen. | **Fixed + verified live R10 (working tree; user commits)** |
| C-HOME-001 | P2 | Home / UI redundancy (Pass C, R10) | **Home shows the top pending action twice.** `HomePriorityEngine` picks a `primaryAction` (rendered as the big `PrimaryHomeActionCard` hero) while `buildPendingActions()` independently re-adds the same item to the "Waiting for you" list — no exclusion of the primary. Seen live: "Challenge waiting" appears both as a compact "Waiting for you" row AND as the prominent hero below it. Applies to every pending type (reveal/partner-answered/game/challenge/date/capsule) whenever the engine's primary overlaps the pending list. | 5554 Home (paired, challenge in progress): two "Challenge waiting" cards stacked (list row + hero). | In `buildPendingActions()` (or at the call site) drop the card whose target matches `primaryAction.target` so each waiting item surfaces once. | **Fixed + verified live R10 (working tree; user commits)** |
| C-SEC-001 | P2 | Security / recovery — wrong store (Pass C+D4, R10) | **SecurityScreen reads the wrong recovery-phrase store, so the accepter can't view/copy their phrase there.** Two stores exist: `RecoveryPhraseStore` (global `closer_recovery/recovery_phrase`) is written **only** by the inviter's `InviteRepositoryImpl.createInvite` (line 43); the accepter's `CoupleEncryptionManager.unwrapAndStore` persists the phrase in `CoupleKeyStore` (`recovery_phrase_$coupleId`, line 71). `SecurityScreen` (`SecurityScreen.kt:82,92`) reads `recoveryPhraseStore.load()` (global) → null for the accepter → row greyed + misleading copy *"becomes available after you invite your partner"*. `EditProfileViewModel` (reads `encryptionManager.recoveryPhrase(coupleId)` → CoupleKeyStore) shows it correctly for both. **D4 verified the accepter DOES hold the phrase and CAN recover the couple key — E2EE recovery is sound (not data-loss); this is a UI wrong-source bug.** | 5554 (QA, accepter): Settings → Security → Recovery phrase greyed + that copy. 5556 (Sam, inviter): active. EditProfile shows it for QA. | Make `SecurityScreenViewModel` read the phrase via `encryptionManager.recoveryPhrase(coupleId)` (CoupleKeyStore, the same source EditProfile uses) instead of the global `RecoveryPhraseStore`; fix the unavailable-state copy. | **Fixed + verified live R10 (working tree; user commits)** |
| J-OBS | P3 | A11y / touch targets | A few conversation icon-buttons measure **~4245dp wide** (48dp tall) — single-axis marginal miss of the 48dp target; fully operable. Most controls are 48dp. | Pass J: uiautomator bounds on conversation → 23 clickables `<126px` wide. | Bump those icon-buttons to 48dp min (e.g. `Modifier.minimumInteractiveComponentSize()` / `size(48.dp)`). | **Open (P3, non-blocking)** | | J-OBS | P3 | A11y / touch targets | A few conversation icon-buttons measure **~4245dp wide** (48dp tall) — single-axis marginal miss of the 48dp target; fully operable. Most controls are 48dp. | Pass J: uiautomator bounds on conversation → 23 clickables `<126px` wide. | Bump those icon-buttons to 48dp min (e.g. `Modifier.minimumInteractiveComponentSize()` / `size(48.dp)`). | **Open (P3, non-blocking)** |
## Resolved & confirmed (archived — full detail in git history) ## 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-DS-001 · C-NAV-001 · D-001 · E-001 · E-002 · E-003 · E-OBS · F-OBS · F-RACE-001 · **I-001** · **I-002** — all fixed and re-verified (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-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-GAME-002** · E-OBS · F-OBS · F-RACE-001 · **I-001** · **I-002** — all fixed and re-verified (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) ## 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. - **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.
@ -49,6 +59,7 @@ A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-
- **Robustness:** malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads. - **Robustness:** malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads.
## Round history (one line each) ## Round history (one line each)
- **R10 (2026-06-26) — FULL ClaudeQAPlan run AJ + fix phase.** Found 5 P2 in report-only passes, fixed + verified all live: C-HOME-001 (Home dup pending card), C-NAV-002 (wheel results→BACK re-entered finished session), C-NAV-003 (duplicate app bar on Wheel History/PartnerHome), C-PW-001 (dark paywall pills light-on-light), C-SEC-001 (Security read wrong recovery-phrase store → accepter couldn't view phrase; E2EE recovery itself sound). E-GAME-002 confirmed live (startNotifiedAt set + partner_started_game→right partner + foreground banner + Join→joined active ToT) → pruned. D1D7 security clean (non-member denied all raw-API reads/writes, no self-grant, secure-subdoc gate correct, argon2id+AAD=coupleId). Concurrency double-start→1 session. Perf jank 5.53% / a11y font-2.0 reflows — no regression. Build OK, both emulators reinstalled, 0 FATAL, content still `enc:v1:`. App fixes in working tree (user commits).
- **Notif→game fix + dark art + QA sweep (2026-06-26, uncommitted).** **E-GAME-001 (P1, FIXED+VERIFIED):** game notifications "led nowhere" — backgrounded/warm taps landed on Home (MainActivity was standard launch mode → `onNewIntent` never delivered the tap's extras → `pendingDeepLink` unset), and even when routed, the game screen showed *setup* instead of joining (one-shot `getActiveSessionForCouple` raced the post-push Firestore sync → returned stale-empty). Fixes: `AndroidManifest` `MainActivity launchMode=singleTop` + `QuestionSessionRepositoryImpl.getActiveSessionForCouple` now SERVER-first (cache fallback). **Verified live:** Sam backgrounded → taps partner_started_game → lands IN the active This-or-That (1/10), joined, no duplicate session; back-stack sane (game→back→Home→back→exit, C-NAV-001 holds). Generic across game types (shared routing + getActiveSession). **Dark-theme art:** 12 `_dark` variants → `drawable-night-nodpi/` (light names) so dark mode auto-swaps; verified live (Security shows the aubergine variant on dark; light unchanged). **QA sweep:** tabs both themes, deep-link back-stack, all 12 illustrations both themes — **0 FATAL**, baseline intact. - **Notif→game fix + dark art + QA sweep (2026-06-26, uncommitted).** **E-GAME-001 (P1, FIXED+VERIFIED):** game notifications "led nowhere" — backgrounded/warm taps landed on Home (MainActivity was standard launch mode → `onNewIntent` never delivered the tap's extras → `pendingDeepLink` unset), and even when routed, the game screen showed *setup* instead of joining (one-shot `getActiveSessionForCouple` raced the post-push Firestore sync → returned stale-empty). Fixes: `AndroidManifest` `MainActivity launchMode=singleTop` + `QuestionSessionRepositoryImpl.getActiveSessionForCouple` now SERVER-first (cache fallback). **Verified live:** Sam backgrounded → taps partner_started_game → lands IN the active This-or-That (1/10), joined, no duplicate session; back-stack sane (game→back→Home→back→exit, C-NAV-001 holds). Generic across game types (shared routing + getActiveSession). **Dark-theme art:** 12 `_dark` variants → `drawable-night-nodpi/` (light names) so dark mode auto-swaps; verified live (Security shows the aubergine variant on dark; light unchanged). **QA sweep:** tabs both themes, deep-link back-stack, all 12 illustrations both themes — **0 FATAL**, baseline intact.
- **Brand art drop (2026-06-26) — wired + QA-swept, 0 issues.** All 11 generated illustrations (A1A12, source gitignored) wired into their screens via shared `EmptyState` + new `BrandIllustration` helper (commits `077a408`→`5868d06`). **Complete both-theme sweep:** in-context dark **and** light verified for Bucket List (A6), Quiet hours (A9), Security (A11), Delete account (A12) — all render as crisp rounded tiles, on-brand, no clipping/contrast issue; A1 (transparent), A3 (banner) + the empty-only states (A2/A4/A5/A8/A10, unreachable on the baseline couple) verified via the debug Art-preview gallery on both themes + the proven shared tile. **0 FATAL/ANR** both devices; baseline intact (0 sessions/outcomes). Process catch: 5556 was on a stale build mid-sweep → reinstalled current, both now on `768f511`. Details in `ClaudeBrandingReview.md`. - **Brand art drop (2026-06-26) — wired + QA-swept, 0 issues.** All 11 generated illustrations (A1A12, source gitignored) wired into their screens via shared `EmptyState` + new `BrandIllustration` helper (commits `077a408`→`5868d06`). **Complete both-theme sweep:** in-context dark **and** light verified for Bucket List (A6), Quiet hours (A9), Security (A11), Delete account (A12) — all render as crisp rounded tiles, on-brand, no clipping/contrast issue; A1 (transparent), A3 (banner) + the empty-only states (A2/A4/A5/A8/A10, unreachable on the baseline couple) verified via the debug Art-preview gallery on both themes + the proven shared tile. **0 FATAL/ANR** both devices; baseline intact (0 sessions/outcomes). Process catch: 5556 was on a stale build mid-sweep → reinstalled current, both now on `768f511`. Details in `ClaudeBrandingReview.md`.
- **R9** — clean confirmation round (**0 new findings**): confirmed + pruned I-001/I-002 (0 outcomes denials/CCE on the fixed build); swept deferred Pass C deep/list screens (Answer History, Activity, Bucket List, Date Match/Matches — both themes) + Pass F network (offline cache render + clean reconnect). 0 open P0P2. - **R9** — clean confirmation round (**0 new findings**): confirmed + pruned I-001/I-002 (0 outcomes denials/CCE on the fixed build); swept deferred Pass C deep/list screens (Answer History, Activity, Bucket List, Date Match/Matches — both themes) + Pass F network (offline cache render + clean reconnect). 0 open P0P2.

View File

@ -416,7 +416,18 @@ fun AppNavigation(
) { ) {
WheelSessionScreen( WheelSessionScreen(
sessionId = it.arguments?.getString("sessionId") ?: "", sessionId = it.arguments?.getString("sessionId") ?: "",
onNavigate = navigateRoute // When the session finishes it navigates to WHEEL_COMPLETE — pop the finished
// play screen off the back stack so BACK from results returns to the wheel
// hub/Play, not back INTO the completed session (C-NAV-002).
onNavigate = { route ->
if (route.startsWith("wheel_complete/")) {
navController.navigate(route) {
popUpTo(AppRoute.WHEEL_SESSION) { inclusive = true }
}
} else {
navigateRoute(route)
}
}
) )
} }
composable( composable(
@ -591,7 +602,9 @@ private val topLevelRoutes = listOf(
) )
private val shellBackRoutes = setOf( private val shellBackRoutes = setOf(
AppRoute.PARTNER_HOME, // NB: PARTNER_HOME, WHEEL_HISTORY, GAME_HISTORY are intentionally NOT here — those screens
// render their OWN Scaffold+TopAppBar (with back). Adding them drew a SECOND app bar + back
// arrow on top of the screen's own (C-NAV-003 duplicate header, same class as C-CC-001).
AppRoute.QUESTION_CATEGORY, AppRoute.QUESTION_CATEGORY,
AppRoute.QUESTION_COMPOSER, AppRoute.QUESTION_COMPOSER,
AppRoute.QUESTION_THREAD, AppRoute.QUESTION_THREAD,
@ -612,8 +625,6 @@ private val shellBackRoutes = setOf(
// on top of the screen's own (C-CC-001 duplicate header / double back). // on top of the screen's own (C-CC-001 duplicate header / double back).
AppRoute.WAITING_FOR_PARTNER, AppRoute.WAITING_FOR_PARTNER,
AppRoute.SUBSCRIPTION, AppRoute.SUBSCRIPTION,
AppRoute.WHEEL_HISTORY,
AppRoute.GAME_HISTORY,
AppRoute.THIS_OR_THAT_REPLAY, AppRoute.THIS_OR_THAT_REPLAY,
AppRoute.DESIRE_SYNC_REPLAY, AppRoute.DESIRE_SYNC_REPLAY,
AppRoute.HOW_WELL_REPLAY, AppRoute.HOW_WELL_REPLAY,

View File

@ -624,13 +624,9 @@ private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit, onAbandon:
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp) shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") } ) { Text("Back to Play") }
TextButton(onClick = onAbandon) { // No "End this game" here: you've already answered and are only waiting for your partner to
Text( // finish. "Back to Play" leaves safely (results push arrives when they finish); abandoning
"End this game", // would silently discard the reveal you're waiting for.
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }

View File

@ -573,7 +573,9 @@ class HomeViewModel @Inject constructor(
val primary = priorityOutput.primary?.let { toHomeAction(it.priority) } val primary = priorityOutput.primary?.let { toHomeAction(it.priority) }
val secondary = priorityOutput.secondary.mapNotNull { toHomeAction(it.priority) } val secondary = priorityOutput.secondary.mapNotNull { toHomeAction(it.priority) }
val pending = buildPendingActions() // The primary action already gets the prominent hero card; drop it from the "Waiting for
// you" list so the same item isn't surfaced twice (C-HOME-001).
val pending = buildPendingActions().filterNot { it.target == primary?.target }
return copy( return copy(
primaryAction = primary, primaryAction = primary,

View File

@ -686,13 +686,9 @@ private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp) shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") } ) { Text("Back to Play") }
TextButton(onClick = onAbandon) { // No "End this game" here: you've already done your part and are only waiting for your partner.
Text( // "Back to Play" leaves safely (results push arrives when they finish); abandoning would
"End this game", // silently discard the reveal you're waiting for.
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }

View File

@ -261,7 +261,10 @@ private fun BenefitPill(label: String) {
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface // The pill background is always the light PurpleMist (not theme-adaptive), so the
// text needs a fixed dark brand color — onSurface is near-white in dark mode and
// rendered the labels invisible (C-PW-001).
color = CloserPalette.PurpleDeep
) )
} }
} }

View File

@ -46,8 +46,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.R import app.closer.R
import app.closer.data.local.RecoveryPhraseStore import app.closer.crypto.CoupleEncryptionManager
import app.closer.ui.components.BrandIllustration import app.closer.ui.components.BrandIllustration
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.SettingsRepository import app.closer.domain.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -68,18 +70,35 @@ data class SecurityUiState(
@HiltViewModel @HiltViewModel
class SecurityViewModel @Inject constructor( class SecurityViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val recoveryPhraseStore: RecoveryPhraseStore private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val encryptionManager: CoupleEncryptionManager
) : ViewModel() { ) : ViewModel() {
// The revealed phrase (null until biometric reveal).
private val _recoveryPhrase = MutableStateFlow<String?>(null) private val _recoveryPhrase = MutableStateFlow<String?>(null)
// The phrase actually stored on this device, resolved once. Read from the per-couple
// CoupleKeyStore (via encryptionManager) — the SAME source EditProfile uses — so the
// ACCEPTER sees their phrase too (the old global RecoveryPhraseStore was only ever
// written by the inviter's create-invite flow, C-SEC-001).
private val _storedPhrase = MutableStateFlow<String?>(null)
init {
viewModelScope.launch {
val uid = authRepository.currentUserId ?: return@launch
val coupleId = runCatching { coupleRepository.getCoupleForUser(uid) }.getOrNull()?.id
_storedPhrase.value = coupleId?.let { encryptionManager.recoveryPhrase(it) }
}
}
val uiState: StateFlow<SecurityUiState> = combine( val uiState: StateFlow<SecurityUiState> = combine(
settingsRepository.settings, settingsRepository.settings,
_recoveryPhrase _recoveryPhrase,
) { settings, phrase -> _storedPhrase
) { settings, phrase, stored ->
SecurityUiState( SecurityUiState(
biometricLoginEnabled = settings.biometricLoginEnabled, biometricLoginEnabled = settings.biometricLoginEnabled,
hasRecoveryPhrase = recoveryPhraseStore.load() != null, hasRecoveryPhrase = stored != null,
recoveryPhrase = phrase recoveryPhrase = phrase
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SecurityUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SecurityUiState())
@ -89,7 +108,7 @@ class SecurityViewModel @Inject constructor(
} }
fun revealRecoveryPhrase() { fun revealRecoveryPhrase() {
_recoveryPhrase.update { recoveryPhraseStore.load() } _recoveryPhrase.update { _storedPhrase.value }
} }
fun hideRecoveryPhrase() { fun hideRecoveryPhrase() {
@ -229,7 +248,7 @@ fun SecurityScreen(
if (!state.hasRecoveryPhrase) { if (!state.hasRecoveryPhrase) {
Text( Text(
text = "Recovery phrase becomes available after you invite your partner.", text = "Your recovery phrase will appear here once your couple is set up on this device.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = SettingsMuted, color = SettingsMuted,
modifier = Modifier.padding(horizontal = 4.dp) modifier = Modifier.padding(horizontal = 4.dp)

View File

@ -990,13 +990,9 @@ private fun WaitingForRevealScreen(
) { ) {
Text("Back to Play") Text("Back to Play")
} }
TextButton(onClick = onAbandon) { // No "End this game" here: you've already submitted your picks and are only waiting for your
Text( // partner to finish. Leaving via "Back to Play" keeps the session alive (you get a results
"End this game", // push when they finish); abandoning would silently discard the reveal you're waiting for.
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }

View File

@ -178,7 +178,8 @@ private fun SpinWheelContent(
WheelSpinner( WheelSpinner(
isSpinning = state.isSpinning, isSpinning = state.isSpinning,
spunAndReady = state.spunAndReady, spunAndReady = state.spunAndReady,
onSpin = onSpin onSpin = onSpin,
onStart = onStart
) )
} }
@ -339,7 +340,8 @@ private fun ChooseCategoryButton(hasPremium: Boolean, onClick: () -> Unit) {
private fun WheelSpinner( private fun WheelSpinner(
isSpinning: Boolean, isSpinning: Boolean,
spunAndReady: Boolean, spunAndReady: Boolean,
onSpin: () -> Unit onSpin: () -> Unit,
onStart: () -> Unit
) { ) {
// Accumulated spin angle — increases with each spin, never resets. // Accumulated spin angle — increases with each spin, never resets.
// Using a box so LaunchedEffect can write into it without triggering recomposition of its parent. // Using a box so LaunchedEffect can write into it without triggering recomposition of its parent.
@ -421,8 +423,10 @@ private fun WheelSpinner(
} }
Surface( Surface(
onClick = onSpin, // Once spun, the center reads "Ready" and tapping it starts the session
enabled = !isSpinning && !spunAndReady, // (same action as the "Start session" button); before spinning it spins.
onClick = { if (spunAndReady) onStart() else onSpin() },
enabled = !isSpinning,
modifier = Modifier modifier = Modifier
.size(94.dp) .size(94.dp)
.scale(centerScale), .scale(centerScale),