Commit Graph

454 Commits

Author SHA1 Message Date
null 7b8fa652f6 feat(avatar): frame a picked photo, and fall back to initials not a heart
Initials, shared. Home's partner bubble and the Settings "Connected with" row
are two views of one person and gave different answers when there was no photo —
Home showed initials, Settings showed a heart. Settings now shows the partner's
initials in a 48dp circle, so the photo and no-photo states share one
silhouette. The helper is lifted to ui/components/Initials.kt rather than copied;
an identical copy in two files is exactly how packArtworkRes drifted. The
unpaired state keeps the heart — there is nobody to take initials from yet.

Framing. Picking a photo now opens a crop sheet: pinch to zoom, drag to move,
inside the real avatar circle. The avatar was a blind ContentScale.Crop, so a
non-square or off-centre photo was centre-cropped and could lose the subject —
Ava's 640x480 test photo cropped straight past her face. The crop is applied at
pick time and handed back as a normal Uri, so setPhotoUri → upload is untouched.
EXIF orientation is honoured (a portrait selfie would otherwise crop sideways),
output is a 512px JPEG, and the source aspect is respected.

Two bugs found by driving it live, both of which looked fine in code:
- Panning did nothing at 1x. The clamp was viewport*(scale-1)/2, which is 0 at
  1x — correct only for a square source. A cover-fit 640x480 photo already
  overflows horizontally at 1x, so that pinned it dead centre and made framing
  impossible: the whole point of the sheet. Now clamped to the picture's actual
  overflow, computed from the source aspect.
- Panning then revealed empty space at the circle's edge. ContentScale.Crop had
  already discarded the overflow and returned a viewport-sized node, so
  graphicsLayer was sliding an already-cropped square around — the pixels being
  panned to had been thrown away first. The preview now draws the bitmap itself
  at cover*scale with the same maths as the crop, so preview == output.

Adds androidx.exifinterface. Verified live on 5554: sheet opens, pan moves the
picture (pixel-probed), no empty edge. 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:19:37 -05:00
null 76d8746a9b fix(settings): the paired "Connected with" card rendered muddy and unpolished
Two defects, both only in the paired state — the unpaired state was already
styled correctly, which is why this hid.

1. The card was see-through. `partnerCardColor` used primaryContainer at 52%
   alpha, and the page behind it is a gradient brush, so the gradient bled up
   through the card: it read muddy against the crisp profile card directly above
   (which is 96%), and the card's own content bounds showed as a paler,
   sharp-edged band inside it — measured, not guessed: a 24-unit jump
   (218,209,226 -> 242,232,251 -> 217,208,225) across the Row's 18dp content
   inset. Now 96% like its sibling; the same scanline is flat (240,229,253 ->
   241,231,255). Content colours are the container's own on* pair rather than
   the page's ink, so contrast is correct in both themes.

2. The heart was a bare, outsized glyph. ProfileAvatar's no-photo fallback is a
   40dp Icon with no container, so the paired row showed a naked heart unlike
   anything else on the page — while the *unpaired* branch right below it, and
   every settings row, use a 48dp tile + 24dp glyph. The avatar is now used only
   when a real partner photo exists; otherwise it falls back to that same tile.

Verified live both fixtures (dark 5554 / light 5556) with pixel probes before
and after. theme-scan REVIEW 25 -> 24 (one hardcoded-colour hit removed);
CRITICAL/MAJOR unchanged. Unit tests green, 0 FATAL.

Filed alongside this round's Pass C results (R32) in ClaudeReport/ClaudeQACoverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:29:05 -05:00
null 089437e415 fix(nav): give the question pack library a way back
The pack library was a navigation dead end: it is not a bottom-nav tab and it
draws no header of its own, and it was missing from shellBackRoutes — so the
shell rendered neither an app bar nor a bottom bar. Once there, the system Back
gesture was the only way off the screen.

It is reached by drilling in from four places (Home "All packs", the Play hub,
the question composer's empty state, and the weekly recap), so it needs the same
shell back affordance its own detail page (QUESTION_CATEGORY) already had.

Audited the whole route table for the same hole rather than patching just this
one. Every other non-tab route either sits in shellBackRoutes or draws its own
back; the two remaining are PAIR_PROMPT and RECOVERY, which are self-contained
entry flows with their own CTAs, so they are intentionally left alone.

Verified live on both fixtures: Home -> Question Packs -> a pack -> back ->
Question Packs -> back -> Home, in dark and light. Unit tests green, 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:31:13 -05:00
null 66d38251ea fix(packs): show pack name and description in full on library cards
The library cards clipped the two things a card exists to communicate: the name
at maxLines=1 ("Communicati…") and the description at maxLines=2 ("…about
pers…"). Both are now uncapped and cards size to their content, matching the
pack detail page.

Removing the caps alone was not enough. The question-count pill shared the title
row, so it reserved width for the row's full height and squeezed the text into a
narrow column — the name then broke mid-word ("Communicatio/n") and the
description wrapped to a ragged 8 lines. The pill moves down to join the access
pill, which gives the text the card's full width and matches how the detail page
already groups its pills.

Verified live on both fixtures (dark 5554 / light 5556): full names on one line,
full descriptions across the card, pills grouped below. Unit tests green,
0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:24:26 -05:00
null 8aea4730cc perf(res): convert all artwork to WebP; unify + blend pack art
Converts every PNG in res to WebP (124 files, 104M -> 28M on disk, -73%).
Each file was encoded both lossless and lossy q95 and the smaller kept, but
lossy only where it measured visually lossless (PSNR >= 40 dB); dimensions and
alpha were verified per file before the PNG was removed, so 25 files that
compressed better losslessly stayed exact. No nine-patches exist to break, and
R.drawable refs are extension-agnostic (the only ".png" in code is a runtime
share-cache filename). Debug APK 141.9 -> 128.8 MB; pack art in the APK
26.4 -> 11.5 MB. The disk saving is larger than the APK saving because AAPT2
already crunched PNGs at build time.

Unifies pack artwork in a new PackArtwork.kt. packArtworkRes was duplicated
verbatim in the library and detail screens — which is exactly how the two
drifted before (one kept a grouped mapping that gave several packs the same
illustration). It now exists once, alongside the two art composables. This is
also where an imported pack's art would resolve.

Pack detail page fixes:
- Removed the second back arrow. The nav scaffold already supplies the
  "Question Pack" bar and its back affordance (AppRoute.kt), so the in-screen
  IconButton was a duplicate; onBack is now unused and gone.
- The description is shown in full. It was capped at maxLines=4 with an
  ellipsis, which truncated most packs mid-sentence — this is the page where
  someone decides whether to open the pack, so it should not be abridged.
- The hero art is blended instead of pasted on: it runs full-bleed (the list no
  longer pads horizontally; items pad themselves) and dissolves into the page.
  The dissolve is an alpha mask (BlendMode.DstIn), not a scrim in the
  background colour — the page behind is a gradient brush, so fading to any
  single colour left a visible pale band in light theme. Fading the image to
  transparent lets the real background through, correct in both themes.
  Library cards get a softer version of the same, into the card surface.

Verified live on both fixtures (dark 5554 / light 5556): single back arrow,
full description, art melting into the page with no hard edges, correct
per-theme art. Unit tests green, 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:52:14 -05:00
null 388b927437 feat(packs): add distinct pack artwork 2026-07-14 20:18:56 -05:00
null 1ac724bcde fix(content): give packs a pack_id, and stop icon_name leaking into the UI
Populates question.pack_id (build_db.py). The column was plumbed end to end
(question.pack_id -> QuestionEntity.packId -> Question.packId) but was NULL for
all 3811 rows, so content had no pack identity. Bundled packs now carry one —
the logical id a pack declares in metadata (daily ships as category
daily_fun_mc but is logically daily_single_choice_weekly_v1), else its category
id. This is the hook a purchased/imported pack needs: it becomes just another
pack_id in the same table rather than a special case. No schema or behaviour
change; identity hash unchanged.

Fixes two regressions the rebuilt db exposed, both latent for the same reason:
every category previously carried the placeholder icon_name "question", which
masked them. Packs now declare real Material icon names (shield, forum, paid).

1. Category glyphs. categoryGlyphStyle keyed on `iconName ?: categoryId`, so
   icon_name won. Material names are a different vocabulary from this file's
   keys, so 22 of 23 categories fell through to the default star. The curated
   per-category glyph now wins, with icon_name as the fallback — which is also
   what gives an imported pack a real glyph, since its category_id will not be
   listed here but its declared icon_name resolves. Added the Material aliases
   and the two unmapped categories (quality_time, daily_fun_mc).
2. Pack library chips. metadataLabels() rendered the raw icon_name as a
   user-facing tag, producing chips like "Chat Bubble Outline". It was only ever
   invisible because the list filtered the single value "Question". icon_name is
   a rendering detail, not a topic, so the chip is gone; access remains.

Verified live on both fixtures (dark 5554 / light 5556): distinct correct glyphs
(Boundaries warning, Communication chat, Rebuilding Trust shield, Sex & Desire
heart-outline), no leaked chips, and the new Quality Time pack browsable with
150 questions and a calendar glyph. Unit tests green, 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:10:37 -05:00
null 8e6dabd4a4 feat(seed): rebuild asset db from JSON; verify Room loads it on-device
Regenerates app/src/main/assets/database/app.db with the fixed build_db.py, so
the app finally ships the current question catalog. The db had drifted far from
the JSON source of truth: 6103 -> 3811 questions (the intentional 150-cap trim),
+150 quality_time (a category the app could not show at all), and daily 500 ->
511 — the 11 wildcards, whose feature (DailyModeResolver) was built but had zero
content in the db, so wildcard days were dark. Identity hash is unchanged
(7e7d78fc...), schema untouched, so no migration is involved.

Scale labels now actually render. The db stored snake_case answer_config while
the app's only parser (QuestionMapper.parseAnswerConfig) reads camelCase, so
optString("minLabel","") resolved to "" and the scale UI fell back to bare
numbers. The rebuild emits the shape the parser reads.

Adds AssetDatabaseVerifyTest (androidTest): Room opens the asset lazily, so an
app that launches proves nothing about it — the bundled db is only validated on
first DB touch, and a bad one crashes every user on a fresh install. The test
forces a real open via openHelper.readableDatabase (triggering the copy +
identity/schema check) and asserts content, no orphan category_ids, integer
depth, and the camelCase scale contract. Verified 4/4 green on a clean install
on throwaway emulator 5558; the 5554/5556 fixtures were never touched.

Also stops shipping ~6MB of dead weight: app.db.bak_q4 and app.db.bak_q5 were
tracked inside assets/, and everything under assets/ is packaged into the APK.
build_db.py was making it worse by writing its backup next to the db; backups
now go to build/db-backups/ (gitignored, outside the packaged tree) and the
stale ones are removed. APK now contains only assets/database/app.db.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:49:20 -05:00
null 757d4efdda test(home): robust assertion in render smoke's unanswered-state case
The unanswered case asserted a specific CTA ('Answer privately') that the preview's
demo state doesn't surface in the test viewport. Assert the stable header instead —
still exercises the UNANSWERED render path (setContent), just without a brittle text
match. Verified 3/3 green on a throwaway emulator (fixtures untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:54:17 -05:00
null ed0aa4d3b3 fix(test): HomeContentRenderSmokeTest nested-comment (components/* opened a block comment)
'ui/home/components/*' in the KDoc contains a /* sequence, which Kotlin treats as
a NESTED block-comment opener (Kotlin nests block comments) -> unclosed comment.
Reworded. androidTest now compiles. (App was never affected.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:49:06 -05:00
null 1eacf36d81 fix(test): HomeContentRenderSmokeTest unclosed-comment (emoji in KDoc broke Kotlin lexer)
The prior commit's androidTest didn't compile — a stop-sign emoji in the KDoc
tripped the Kotlin lexer ('Unclosed comment'). Replaced with plain text. App was
never affected (androidTest isn't in the APK); this restores connectedAndroidTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:47:59 -05:00
null f7eb03417b test(home): HomeActionMapperTest (JVM) + HomeContentRenderSmokeTest (instrumented)
Part 3 of the Home refactor. Adds the durable coverage the plan called for:
- HomeActionMapperTest: 12 JVM cases over the lifted pure mapper (refresh states,
  withHomeActions pairing/daily/loading/error paths, toHomeLabel mc-drop, secondary
  cap of 3, C-HOME-001 primary/pending dedup) — locks the extraction as faithful.
- HomeContentRenderSmokeTest: instrumented render net for Home (via the VM-free
  PairedHomePreviewScreen), light + dark — catches 'composes fine, crashes on
  first paint'. Runs on a THROWAWAY only (uninstalls app-under-test).

JVM test green; androidTest compiles. Fills the 'Home has no UI test' gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:46:16 -05:00
null 9e81c15350 perf(home): @Immutable on HomeAction/PendingActionCard/HomeCategorySummary
Part 2b (Compose stack advantage). Marks the three verified-deeply-immutable
card models @Immutable so their single-instance params get structural-equality
skipping (upgrade over the K2 reference-equality strong-skipping default).
HomeAnswerStats/HomeUiState left unannotated (LocalAnswer/Question/Set transitive
types not fully audited — a false @Immutable promise would cause stale UI).
Rendered output identical; only recomposition frequency drops. compile green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:42:04 -05:00
null 7be9a1bf15 refactor(home): lift pure action mapper -> HomeActionMapper.kt (VM 867->562)
Part 2b. Moves the ~305 lines of pure HomeUiState action-derivation extensions
(refreshDailyQuestionState/withHomeActions internal; toHomeAction/
buildDailyQuestionAction/buildPendingActions/has*/toHomeLabel private) out of
HomeViewModel verbatim (de-indented, byte-identical bodies). Verified pure — no
this@HomeViewModel/repo refs. VM call-sites unchanged (same-package extensions).
compile + full unit suite green. HomeViewModel now 562 lines (from 1030).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:40:31 -05:00
null 227d141221 refactor(home): extract data models -> HomeModels.kt (VM 1030->867)
Part 2a. Moves the Home data classes/enums + computeDailyQuestionState (kept
@VisibleForTesting internal) + gameRouteFor (widened private->internal, its only
caller loadHome is same-package) out of HomeViewModel into HomeModels.kt.
Same package -> no consumer import churn (AppNavigation, DailyQuestionStateTest
unchanged). Behavior-identical. compile + ui.home unit tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:38:29 -05:00
null 415f8d6866 refactor(home): extract StreakMilestoneDialog + drop dead MomentCueCard
Step 9/9 of the UI split. StreakMilestoneDialog -> components/ (kept internal;
ArtPreviewScreen call updated to the new package). MomentCueCard was dead (no
callers) — removed. Orphaned imports cleaned. compileDebugKotlin green.
HomeScreen.kt now ~620 lines (from 1921).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:34:37 -05:00
null 94360a5438 refactor(home): extract header + partner sheet -> components/HomeHeaderSection.kt
Step 8/9. Verbatim move (HomeHeader + PartnerQuickActionsSheet public, rest private;
@OptIn(ExperimentalMaterial3Api) + .semantics a11y + full state param preserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:43:22 -05:00
null c13dbc5bfd refactor(home): extract primary/activation cards -> components/HomePrimaryCards.kt
Step 7/9. Verbatim move (activation + primary hero public, benefit-pill private).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:40:08 -05:00
null d324efe182 refactor(home): extract status strip -> components/HomeStatusStrip.kt
Step 6/9. Verbatim move (strip public, chip private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:32:54 -05:00
null 48197ffd1e refactor(home): extract action feed -> components/HomeActionFeed.kt
Step 5/9. Verbatim move (section public, card private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:31:42 -05:00
null 88460922c3 refactor(home): extract pending cards -> components/HomePendingCards.kt
Step 4/9. Verbatim move (section public, card-view private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:30:25 -05:00
null 07c810c9a1 refactor(home): extract category preview -> components/HomeCategoryPreview.kt
Step 3/9. Verbatim move (grid public, mini-card private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:29:06 -05:00
null 18ad31506e refactor(home): extract loading/error/empty -> components/HomeStateCards.kt
Step 2/9. Verbatim move, public entries. compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:27:52 -05:00
null 5f39194c00 refactor(home): extract shared card kit -> components/HomeActionStyle.kt
Step 1/9 of the HomeScreen decomposition. Moves the 6 shared style decls
(homeActionGlyph, HomeGlyphIcon, homePrimaryArt, HomeActionColors,
HomeActionTone.actionColors, HomePill) verbatim into ui/home/components/, made
public per the components/ visibility idiom. No behavior change. compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:26:08 -05:00
null a63bf350b1 fix(crash): LocalDate.ofInstant crashes on API<34 + add Lint/R8 CI gates
Adding Android Lint to CI immediately caught two real crash bugs invisible to
all emulator QA (the fixture emulators run API 34+):

- SettingsViewModel + YourProgressViewModel called LocalDate.ofInstant, which
  was only added in API 34 — but minSdk is 26. On every device running Android
  8-13 (the bulk of the install base) these throw NoSuchMethodError and crash
  the Settings and Your Progress screens. Fixed with the API-26-safe equivalent
  Instant.atZone(zone).toLocalDate() (same result).

The other two Lint errors were false positives (ProduceStateDoesNotAssignValue
on two EncryptedChatImage composables that DO assign value inside the producer —
the check misfires on a suspend/?.let RHS) — explicitly @Suppress'd with a note,
so Lint reaches 0 errors legitimately rather than via a blanket baseline.

CI (android-ci.yml) gains two jobs:
- android-lint: ./gradlew :app:lintDebug (fails on error-severity; 113 existing
  warnings are non-fatal and left for a separate burndown).
- release-build: first-ever R8 gate — builds :app:bundleRelease with a throwaway
  keystore + dummy RC_API_KEY (satisfies the release guards; AAB not distributed),
  so the minify/shrink/sign toolchain can never silently rot. Verified locally:
  bundleRelease SUCCEEDS today (95MB AAB). A green build proves the toolchain,
  not runtime survival of reflectively-loaded classes (Tink) — that stays a
  release-APK QA item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:41:04 -05:00
null 4e807cb088 fix(settings): Manage-subscription deep link used namespace, not applicationId
ExternalLinks.MANAGE_SUBSCRIPTION pointed Play's subscription manager at
package=app.closer (the code namespace); the Play package is the applicationId
closer.app. In production the Manage button would miss the app's subscription
entry. Invisible to all QA so far because no real subscription has ever existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:22:03 -05:00
null 6668a3c4b7 refactor(brand): in-app prose -> 'Closer Couples' (launcher + wordmark stay 'Closer')
Extends the store/marketing rename to in-app user-facing PROSE, per owner decision.
Every string where 'Closer' names the app in a sentence now reads 'Closer Couples'
(Android + iOS): settings, pairing, invite share, widget headlines, paywall
subhead/thank-you, age gate, privacy body.

Deliberately KEPT as 'Closer' (judgment calls, documented in docs/NameChange.md):
- launcher label / iOS CFBundleDisplayName (14 chars truncates under the icon)
- wordmark logos (onboarding, auth alt-text, widget header, share-card drawText,
  iOS onboarding) + their asserting tests
- 'Grow Closer' onboarding pun (intimacy, not the app name)
- entity/length-capped lines: privacy rotator 'so Closer cannot read them'
  (hard 64-char cap), iOS 'not even Closer's servers'
- iOS 'Settings > Closer' help path (must match the kept display name)
- 'Closer Premium' tier label (matches the RevenueCat entitlement display name)
- all technical identifiers (app.closer / closer.app / closer_premium / etc.)

TodayWidgetTest exact-match assertions updated. compileDebugKotlin + unit tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 17:07:31 -05:00
null 2c3c2df8c4 fix(copy): reconcile divergent premium benefit lists + drop inaccurate claim
The paywall and subscription screens showed two different premium benefit lists (centralizing
into CloserCopy surfaced the drift). Unified to a single CloserCopy.premiumBenefits used by both.

Also an accuracy fix: dropped "Exportable memories" — it directly contradicts the app's own
privacy copy ("Closer does not currently offer a data export", strings.xml privacy_no_export_body),
i.e. a benefit the app explicitly does not provide. Remaining items are all verified real features
(QuestionComposer, Connection Challenges, Desire Sync, Memory Lane, date planning, answer history).

compileDebugKotlin clean; single source of truth for both surfaces going forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:50:29 -05:00
null 23cd213953 refactor(copy): Slice 1 — dedup age-gate copy into CloserCopy.AgeGate
The 18+ min-age error was triplicated verbatim (SignUpViewModel, CreateProfileViewModel ×2);
consolidated to CloserCopy.AgeGate.ageError(minAge). Also lifted the two disclaimer variants
(kept distinct: brief on sign-up, with-reason on the DOB step — not unified) and the duplicated
"Please enter your date of birth." prompt. 6 call sites now source from the catalog; copy is
byte-identical (no behavior change), compile clean.

Adds docs/CopyMigration.md — the migration plan, scoped (after a gap review) to the ~15-25
brand-voice/duplicated lines, NOT all ~281 UI literals: the bulk stays inline until the single
i18n → strings.xml pass, since double-migrating to a Kotlin catalog first is wasted work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:39:04 -05:00
null 0195564d25 docs(readme): refresh ready-state home screenshots 2026-07-09 00:30:27 -05:00
null 51efff3f78 refactor(copy): start CloserCopy catalog — migrate paywall + subscription voice copy
Establishes a typed Kotlin copy catalog (ui/brand/CloserCopy.kt), sibling to the existing
CloserBrandCopy (privacy rotator). Rationale over strings.xml: the app is English-only and
pre-launch, so a typed catalog gives one-place brand-voice review + compile-time safety
without Compose stringResource() friction or orphaned-string drift. strings.xml's real value
is the localization pipeline, which we migrate to in one pass when i18n is on the roadmap
(gate recorded in Future.md).

First slice (highest-value, monetization surface):
- PaywallScreen + SubscriptionScreen voice copy (benefit lists, headlines, value props,
  "Thank you for supporting Closer", couple-shared taglines) now source from CloserCopy.
- Generic chrome ("Continue", "Restore", "Manage subscription", error-retry) intentionally
  stays inline — it isn't brand voice.
- Surfaced real copy drift: the paywall and subscription benefit lists differ (swap two
  items + reorder) — co-located and flagged in CloserCopy.kt for a copy decision, left
  verbatim (not silently unified).

compileDebugKotlin clean; no behavior change (copy identical, just relocated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:26:15 -05:00
null 59e033692b chore(cleanup): purge 55 unused string resources
strings.xml had 127 entries but the app renders copy from hardcoded Compose literals
(only ~4 files use stringResource) — so ~half of strings.xml was a resource catalog that
was pre-created for the pairing/home/partner-home/settings-nav screens and never wired up.

Removed the 55 entries with zero R.string./@string references anywhere (kt/xml/manifest),
including whole dead sections (Settings nav labels, all Pairing subsections, Home screen,
Partner home). Kept everything actually referenced: app_name, today_widget_description,
common actions in use, and the Appearance/Notifications/Account/Privacy sections.
Resources compile clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:20:32 -05:00
null 21392ec2f3 chore(cleanup): remove verified dead files (Tier 1 dead-file sweep)
Removes files confirmed unreferenced by symbol-level git-grep and proven safe by a
clean :app:compileDebugKotlin + compileDebugUnitTestKotlin (no main/test references).

Dead code (every top-level type had 0 external references):
- core/notifications/NotificationHelper.kt — dead duplicate of the live
  NotificationChannelSetup (which is what CloserApp/AppMessagingService actually call)
- core/notifications/NotificationPermissionHelper.kt — unused permission helper
- notifications/PartnerNotificationScheduler.kt — superseded by PartnerNotificationManager
- data/questions/QuestionJsonParser.kt — superseded by the Room/asset-DB path
- data/repository/FakeQuestionRepository.kt — orphaned fake, no test/DI consumer
- ui/questions/QuestionDetailViewModel.kt — superseded VM, no composable binds it
- domain/model/{Entitlement,InviteStatus,QuestionSessionStatus}.kt — unused models/enums
  (entitlement state is read as Firestore booleans, not this model)

Stray artifacts:
- gitleaks-current.json / gitleaks-history.json — sanitized scan output (history = []),
  referenced by no CI/config; regenerable
- 19 stale .gitkeep placeholders in directories that now hold real files

Deliberately KEPT (flagged but not dead): WheelHistoryScreen/ViewModel.kt (named
"WheelHistory" but declare the live, nav-wired GameHistoryScreen/GameHistoryViewModel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:10:40 -05:00
null b47302d6b7 docs(readme): refresh daily question screenshots 2026-07-08 14:53:12 -05:00
null 53a821a80d fix(home): use people art for daily question card 2026-07-08 14:39:33 -05:00
null 6bd28d34e4 fix(home): use daily question art before answers 2026-07-08 14:29:12 -05:00
null b99a83388e fix(billing): link RevenueCat identity to Firebase auth + purchase UX
Critical fix: Purchases was never told the Firebase uid, so RevenueCat assigned its
own anonymous app_user_id. The revenueCatWebhook Cloud Function writes premium status
to users/{app_user_id}/entitlements/premium using that id — meaning a real purchase
would silently never unlock premium for the signed-in account, and CouplePremiumChecker's
partner-side read (same path, different uid) was broken by the same root cause.

RevenueCatBillingRepository now collects AuthRepository.authState and calls
Purchases.awaitLogIn(uid) on sign-in / awaitLogOut() on sign-out, guarded against
redundant calls and wrapped best-effort so a failed sync retries on the next auth event
instead of crashing the singleton.

Also:
- Bump com.revenuecat.purchases 8.20.0 -> 10.12.0 (verified: real published version,
  stable API surface across 8->10 per RevenueCat's own migration notes for the calls
  this app uses; confirmed resolved + full Hilt/KSP graph compiles clean).
- Purchase cancellation (user backs out of the Play billing sheet) is now distinguished
  from a real failure via PurchasesTransactionException.userCancelled, using a shared
  PURCHASE_CANCELLED_SENTINEL (same marker-constant idiom PaywallViewModel already uses
  for offering-load failures) so PaywallViewModel resets silently instead of surfacing
  the SDK's internal error text.
- PaywallScreen: genuine purchase errors (billing unavailable, network, etc.) now show
  a snackbar. Previously there was zero user-facing feedback on a real purchase failure
  beyond the loading spinner disappearing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 14:19:58 -05:00
null 694294c2e5 fix(ui): remove duplicate answer back button 2026-07-07 23:09:14 -05:00
null ca67187754 fix(ui): strip internal 'mc' token from the reveal-screen category chip too
The daily reveal chip used displayCategoryName() (a different surface than the
Home pill fixed earlier), so it still showed 'Daily Fun Mc'. Fixed centrally in
the shared helper. Verified live: reveal now shows 'Daily Fun'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:04:54 -05:00
null fda53175a9 feat(games): nudge + per-game copy + score a11y batch C4 from UX review
- Waiting-on-partner screen: 'Send a little nudge 💜' button (shown while the
  partner is still playing, not once it's your turn) reusing the generic
  thinking-of-you callable (10/day, quiet-hours-safe server-side); one-shot
  Toast on result incl. friendly rate-limit copy. Verified live: nudge →
  partner_activity push landed on the partner.
- Per-game banner copy: YOUR_TURN/RESULTS in-app banner now branches on gameType
  ('Your turn — guess their answers' for How Well, 'only mutual yeses ever show'
  for Desire Sync, etc.) instead of one generic line; mirrored in the Cloud
  Function's partner_completed_part push (yourTurnBody). Verified live.
- Accessibility: merged contentDescription on the This or That MatchScoreBadge
  ('You matched on N of M') and the How Well score ring Canvas ('You guessed N
  of M correctly') — both were split/Canvas visuals invisible to TalkBack.

Unit + functions typecheck green; assembleDebug clean. Server copy change needs
a functions deploy (bundled with the C1 finish-guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:50:39 -05:00
null cf8f82357e feat(games): game-loop retention batch C3 from UX review
- NextBeatCard: a self-contained 'what's next' card under every game's results
  CTAs so finishing a game doesn't dead-end — shows 'Tonight's question is ready
  →' when today's daily is unanswered, else a 'Day N together' streak note. Own
  @HiltViewModel (DailyQuestionResolver + LocalAnswerRepository + couple), so the
  four game screens only drop in the composable. Verified live on How Well results.
- How Well role swap: the guesser's results now lead with 'Your turn — let {name}
  guess you', starting a new round where they become the subject (Newlywed-style
  reversal; starter == subject). Verified live.
- Date Match: deterministic per-couple deck shuffle (kills 'every couple sees the
  same first card') + skip already-swiped ideas so the deck resumes past them
  instead of restarting at card 1. Verified live (swiped card no longer reappears).
- Desire Sync: remember served question ids per couple (SeenDesireQuestionStore on
  the settings DataStore) so back-to-back rounds don't repeat prompts; clears the
  record to cycle the pool when the unseen remainder can't fill a round.
- How Well pool purity: exclude daily_fun_mc novelty MCs from getQuestionsForPrediction
  (1869 real prediction questions remain); DAO method has no other caller.

Unit suite green; assembleDebug clean; live-verified on the emulator pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:42:39 -05:00
null 9d0a0c7fdf fix(onboarding,challenges): funnel + gating batch C2 from P3/UX review
- DOB picker (sign-up + profile): swap the View-based android.app.DatePickerDialog
  (platform teal) for a shared Compose M3 DobPickerDialog that renders in the app
  palette in both themes; future dates unselectable. Verified live dark = purple.
- SignupHandoff: back the pending DOB with the app's Preferences DataStore keyed
  per-uid, instead of an in-memory singleton that died on process death and made
  CreateProfile re-ask for the birth date after any restart mid-onboarding
  (verified: am kill mid-profile → relaunch → no DOB re-ask). Local-only write,
  so the auth-token/Firestore-rules race the old comment guarded against doesn't
  apply; per-uid key prevents cross-account leakage.
- Connection Challenges: expose statusDay (calendar-actionable day) on
  ChallengeState; the day card shows a 'Day N unlocks tomorrow 🌙' teaser instead
  of spoiling the next prompt the moment today's step is marked done. +2
  ChallengeStateMachine tests.

Unit + functions suites green; verified live on the emulator pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:24:27 -05:00
null 4ab79d34c4 fix(ui,games): defect-polish batch C1 from P3/UX review
- Challenges catalog: hide the 🔒 Premium badge once the couple has premium
  (A-003b; matches the Play hub's showPremiumBadge pattern). Verified live
  both directions under an authorized grant/revoke cycle.
- Game banner lifecycle (BANNER-LIFE-001): entering a session's screen now
  consumes any banner pointing at it (GamePromptController.consumeForSession
  wired into ActiveGameSessionMonitor.enter), and activity from a DIFFERENT
  session may replace a stale persistent banner. Verified live: no banner on
  reveal; stale RESULTS banner replaced by a new session's prompt.
- Waiting/join screen: says 'Your turn — {name} already played their part'
  for the non-starter once a first part landed (new partHasFinished mapped
  from partFinishNotifiedAt; completedByUsers only fills at reveal). Session
  observe mapping now also carries completedByUsers/joinedByUsers.
- How Well results: matched-row colors are now a theme-aware container+content
  pair (dark mode was near-invisible: fixed pale-green container under
  onSurfaceVariant text).
- Date Match: top card fully opaque — next card's text no longer bleeds
  through (was alpha 0.96).
- functions: don't send 'X finished — see your results!' for abandoned/quit
  sessions (status flips to completed with empty completedByUsers; a real
  completion always has both uids). Found live when a quit triggered a false
  banner. Needs deploy (bundled with C4).

Unit + functions suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:03:22 -05:00
null c36a101d38 refactor(cleanup): dead code + hygiene batch from games review
- Delete unused ui/theme/Color.kt (zero refs; live palette is Theme.kt).
- Remove orphaned EditProfileScreen wrapper composable (no route references it;
  AccountScreen embeds EditProfileContent, which stays).
- Debug-gate ArtPreviewScreen + PairedHomePreviewScreen out of the release nav
  graph (BuildConfig.DEBUG).
- Collapse duplicate wheel_history route into game_history (same screen; wheel
  surfaces now navigate to Past Games directly).
- CouplePremiumChecker: inject AuthRepository instead of raw
  FirebaseAuth.getInstance() (finishes the in-flight auth-DI refactor for
  non-data-layer call sites).
- Copy/UI polish: 'It's a match!' (matches the push copy), internal 'mc' token
  never renders in the Home daily pill ('Daily Fun Mc' → 'Daily Fun'),
  welcome/splash privacy tagline no longer clips mid-sentence (maxLines 4 +
  ellipsis, AUTH-TRUNC-001).

Unit + functions suites green; assembleDebug clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:48:56 -05:00
null e5868bd6b1 fix(games,daily): correctness batch from games review
- Game answer listeners (ToT/HowWell/DesireSync): close(err) instead of
  swallowing snapshot errors, + .catch in each VM's observeReveal surfacing a
  retryable ERROR (GameCopy.SYNC_ERROR + retrySync re-attach) — games no longer
  hang on WAITING forever on listener failure (GAME-HANG-001; matches the
  Wheel/Capsule sources' established pattern).
- Daily question: paired fallback pool is now premium-INDEPENDENT so both
  partners always resolve the same deterministic question; viewer-premium pools
  broke the couple contract when entitlement state differed or flipped mid-day
  (DQ-MISMATCH-001, reproduced live: partners answered different questions).
- Daily reveal: humanize raw option-id fallbacks so slugs never render
  (DQ-SLUG-001, e.g. 'fake_awards_should_be_mandatory').
- assignDailyQuestion.ts: replace hardcoded CST_OFFSET_HOURS=-6 with DST-safe
  Intl America/Chicago helpers (DST-001) + 5 regression tests (CDT/CST labeling,
  6PM reveal instant, spring-forward round-trip).

Verified live 2-device: identical daily question on both partners post-fix;
ToT full loop 5/5 reveal regression clean. Unit 244 + functions 58 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:41:41 -05:00
null 0c59000065 refactor(ui): remove raw Firestore listeners from Daily/Partner VMs
DailyQuestionViewModel and PartnerHomeViewModel each held a raw
db.collection(...).addSnapshotListener on the partner's answer doc (just reading
snapshot.exists()). Replace with the existing, proven
FirestoreAnswerDataSource.observeAnswerForUser(...) Flow collected in a
viewModelScope Job (auto-cancelled; previous run cancelled on restart) — dropping
the FirebaseFirestore injection from both. Same behavior (exists → non-null),
now through the data layer.

Verified live: daily question shows the correct "waiting for your partner" state
via the new Flow; compiles + unit suite green.

HomeViewModel's two listeners use MetadataChanges.INCLUDE + inline analytics and
drive the core reveal — left as a scoped follow-on (needs a metadata-aware
data-source Flow + two-device real-time verification). See docs/standardization-review.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:18:30 -05:00
null cce2687312 refactor(ui): inject AuthRepository instead of FirebaseAuth.getInstance()
Five ViewModels reached into the FirebaseAuth.getInstance() static singleton for
the current user id; switch them to the injected AuthRepository.currentUserId
(BucketList, QuestionThread, DateBuilder, MessagesInbox, Conversation). Removes
the Firebase-in-presentation-layer coupling and makes them testable. No behavior
change; compiles + unit suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:12:47 -05:00
null c71817241c build: adopt Gradle version catalog (libs.versions.toml)
Centralize all plugin + dependency versions in gradle/libs.versions.toml (the
modern Gradle standard) and switch the root + app build scripts to the generated
libs.* / alias(libs.plugins.*) accessors. No version changes — a 1:1 migration of
the 57 hardcoded coordinates. Full assembleDebug + unit suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:05:10 -05:00
null fcc0d699fb refactor(data): finish Firebase Tasks→.await() across the data layer
Convert the remaining 10 data sources (Bucket, Conversation, QuestionThread,
DatePlan, DateMemory, DateReflection, Answer, User, Auth, DateMatch) from
verbose suspendCancellableCoroutine{cont-> ...addOnSuccess/addOnFailure} wrappers
(and the per-file getDoc/voidAwait/queryAwait/refAwait helper duplicates) to the
kotlinx-coroutines-play-services .await() extension. The data layer now has ZERO
Task-callback wrappers.

Per-block review preserved non-trivial semantics: callbackFlow snapshot listeners
and the DateMatch transaction are untouched; failure→null/false best-effort blocks
became runCatching{ ...await() }.getOrNull()/getOrDefault() (e.g. markRevealed,
hasProfile, PlayIntegrity.verifyWithServer); no-signed-in-user guards became
`?: throw`.

Verified live (real Firebase, paired account): auth + Home reads (couple/user/
answer/challenge), daily-question answer WRITE (secure payload + metadata sets),
and the streak update all succeed — no crash, no PERMISSION_DENIED. Unit suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:00:55 -05:00
null f160f141d6 chore(gitignore): ignore .code-workspace; cleanup Firebase data source dead code 2026-07-06 21:41:54 -05:00