diff --git a/.gitignore b/.gitignore index 857c847c..23a82c95 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,5 @@ docs/brand/exports/ # Scratch workspace (transient) scratchpad/ +SECURITY.md +Future.md diff --git a/ClaudeQACoverage.md b/ClaudeQACoverage.md index 0a72b0b2..fa89e552 100644 --- a/ClaudeQACoverage.md +++ b/ClaudeQACoverage.md @@ -1,6 +1,7 @@ # Claude QA Coverage Matrix > **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`. +> **R21 (2026-06-29) — brand-voice + Home-bubble polish, then full QA re-run; 0 new defects, 0 FATAL.** Copy: `prompt→question` (~26 strings) + clinical→Closer voice (Outcome/check-in feature, "Your Progress"→"Growing together", "Private sync"→"Just for two", Home eyebrow "Your daily question", paywall "…and growth"). Home partner bubble upgraded (Coil `SubcomposeAsyncImage` + gradient ring + a11y; partner photo verified live). Cheap gates all green (210 unit · 24 fn · theme-scan CRIT 0 · painter-xml 0 · smoke 6/6 both). **Reveal-when-answered verified LIVE end-to-end** (both answer → Home "Reveal is ready / Reveal together" → AnswerReveal shows both picks). Multi-angle nav verified (daily Q via Today+Home, reveal via Home card, Settings→Growing together, Play→Question Packs "250 questions"). Cornerstones A/B/D carry from R20 (no rules/crypto/games-logic change — diff is copy + Home-bubble UI); E re-verified (smoke). Also landed this session (uncommitted): recovery-UX partner-as-backup copy + change-phrase desync guard, `SECURITY.md`, first instrumented test `FirstRunRenderSmokeTest`. See `ClaudeReport.md` R21. > **R20 (2026-06-29) — fresh full ClaudeQAPlan run; found + FIXED 2 escaped bugs.** Build HEAD `62696a6` + R20 fixes (uncommitted: `QuestionSessionRepositoryImpl.kt`, `GameSessionManager.kt`, `HomeViewModel.kt`). Cheap gates all green (unit **210** · fn **24** · theme-scan CRIT **0** · painter-xml **0** · wiring 🔴**0** · smoke **6/6 both**). Cornerstones A/B/D/E live-clean, 0 FATAL. **B-ABANDON-001 (P2)** — Quit/abandon on any game silently `PERMISSION_DENIED` (full `saveSession` set drops server-only flags → rule rejects removed `affectedKeys`) → stranded session; **fixed** (targeted `update(status,completedAt)`), verified live (quit→active=0→new game starts). **B-COPY-001 (P3)** — Home GAME_WAITING hero falsely claimed "partner already played their part"; **fixed** (neutral partner-named copy), verified live both devices. Both pending 1 confirm. See `ClaudeReport.md` R20 run-state. > Build = **R18b working tree** (uncommitted: Wheel finish-gate + `partner_joined_game`/banner-standardization client+functions+rules + portrait lock + docs — full file list in `ClaudeReport.md` run-state); **210 unit + 24 functions tests green**; debug APK rebuilt+installed both emulators. **Deploy status:** `functions/` + `firestore.rules` **DEPLOYED by user** (join push live; Tier-2 self-constraint **verified live** — member own-uid add 200, foreign-uid/removal 403). No remaining deploy gates. Position + verdict: see `ClaudeReport.md` R18b run-state. **R18b polish/hardening round (latest):** fixed **E1 (P2)** Wheel silently-swallowed submit failure → retryable error (no false reveal); modern banner/bubble feel (haptics, spring, JOINED presence dot, tap+swipe, a11y, persistent-not-clobbered); predictive back (`enableOnBackInvokedCallback`); Wheel "Quit game" abandon; Tier-2 rules self-constraint. Pass-E smoke 6/6 both. **Verdict: R18 — fixed the last open visual P2 C-DARKART-002** (uiMode-sync in `MainActivity` via `AppCompatDelegate.setDefaultNightMode` so ALL art follows the in-app theme; verified live across all 4 theme/art states) + flaky **TEST-002** (capsule determinism — injected clock) + content **P-GRAMMAR-001** (13 stress-Q subject-verb agreement errors → asset data fix). Live passes this round: **A** (premium gate, Desire Sync + premium pack → Paywall; free content reachable), **B** (Wheel playthrough end-to-end), **L** (chat E2E send/receive-decrypt/at-rest/receipt/no-leak), **E** (backgrounded FCM delivery, privacy-safe, deep-links to chat), **M-001 confirmed** (client mirror intact). **R18b (Future.md review): found+fixed a P0 — O-ONBOARD-001** onboarding/auth crash on EVERY fresh install (`painterResource` on the `` `ic_launcher_foreground`, a regression from icon-redesign commit 334cb07; invisible to logged-in QA emulators). Verified live before/after on fresh 5558 (API 34); fixed both `OnboardingScreen.CtaSlide` + `AuthVisuals.AuthLogoMark` → raster `closer_launcher_foreground`; added `scripts/painter-xml-scan.sh` guard (proven). Also closed BucketList FAB hardcoded color. **Board: 0 open P0/P1 · 1 open P2 (O-AGE-001 pre-ship age gate) · 1 open P3 (BRAND-DARK-COVERAGE); the full confirmed backlog was pruned this round** (O-ONBOARD-001, C-DARKART-002, 6× C-THEME, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, and **C-ORIENT-001 → RESOLVED: portrait-locked in the manifest, verified live**). 0 FATAL. **R18b feature work (uncommitted; tests 209 unit + 24 functions green):** (1) **Game finish-gate** — no game can finish with unanswered questions (Wheel: skip allowed but Finish bounces to the first blank; the other 3 already require a pick); verified live end-to-end (full 2-player Wheel + This-or-That). (2) **"Partner joined your game" push + standardized durable in-app game banner** — new `partner_joined_game` (joiner's avatar) + all foreground game pushes routed through the themed banner (started/joined transient, your-turn/results persistent); verified live + Pass-E smoke 6/6 both emulators. **⚠ The join push needs `functions/` + `firestore.rules` deployed by the user to fire live.** > diff --git a/ClaudeReport.md b/ClaudeReport.md index f6476774..918c7bd4 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -18,6 +18,7 @@ > to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`. ## Run-state (current) +- **R21 (2026-06-29) — brand-voice + UX polish round, then full ClaudeQAPlan re-run (user: "change the language… more Closer-aligned vs therapy/corporate", "ensure the daily question shows to reveal when answered", "run the full QA plan, get to screens different ways"). 0 new defects, 0 FATAL.** **Copy/UX work (uncommitted):** (1) **Brand-voice sweep** — `prompt → question` across ~26 user-facing strings (Play hub "10 questions", Wheel "Ten questions per spin", Question packs/category/composer/thread, Spin-the-Wheel, Answers, Memory Lane, Home, date ideas; counts/plurals handled; internal ids like `onPickPrompt`/`capsulePrompts`/`promptCountLabel`/`conversationPrompts` + data keys left); **clinical/corporate → Closer voice** — Home eyebrow "Tonight's prompt"→"Your daily question", status chips "Prompt ready"→"Question ready" + "Private sync"→"Just for two"; the **check-in/Outcome feature** rewarmed (survey "How satisfied are you with intimacy?"→"How close do you feel physically?", "How well do you communicate?"→"How easy is it to talk lately?", "Submit"→"Save", "Quick check-in"→"A little check-in"; **"Your Progress"→"Growing together"**, "Baseline/30-day check-in"→"Where you started/30 days in", "Change since baseline"→"Since you started", "…start tracking how your relationship feels…"→"…see how things feel between you two…"); paywall/subscription "…and insights"→"…and growth"; reveal "shared reflection"→"shared moment"; follow-up "ask one deeper follow-up?"→"go one question deeper?". (2) **Home partner bubble upgrade** — modern Coil `SubcomposeAsyncImage` (crossfade + centered-initials loading/error fallback), brand gradient ring, surface-ringed unread badge, a11y contentDescription; verified live (Sam's real photo loads in the ring). **Verification (R21 QA run):** cheap gates ALL GREEN — build + **210 unit + 24 functions**, `theme-scan` CRIT **0** (9 MAJOR/21 REVIEW), `painter-xml` **0**, `entrypoint_smoke` **6/6 on BOTH** emulators; baseline both **free**, **0 active sessions**. **Reveal-when-answered VERIFIED LIVE end-to-end** (the user's ask): answered the daily Q on both (QA "Cozy" via Today tab, Sam "Silly" via Home) → both Homes surfaced **"Reveal is ready / Reveal together"** + "Reveal ready" chip → tapped → AnswerReveal "Both answers are in" → revealed both picks ("Different picks. Honestly, useful."). **Multi-angle nav** (reached screens via different entries): daily Q via Today-tab + Home, reveal via Home card→reveal screen, Settings→"Growing together" (warmed labels render: "No check-ins yet"/"Where you started"/"30 days in"), Play→Question Packs ("250 questions"). All warmed copy renders correctly; **0 FATAL** across the whole session. **Cornerstones:** **E** re-verified live (smoke 6/6 both + partner_answered path); **N** (daily-Q + reveal) live-clean; **A/B/D** carry from R20 (no rules/crypto/games-logic change this session — diff is copy + Home-bubble UI only). **Verdict: R21 — brand-voice + bubble polish shipped + verified live across 5 surfaces; reveal-when-answered confirmed; all cheap gates green; 0 new defects, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001 pre-ship) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked.** Also landed earlier this session (uncommitted): recovery-UX "ask your partner" copy + change-phrase desync guard, `SECURITY.md` (threat model + hardening roadmap), first instrumented test `FirstRunRenderSmokeTest` (proven to catch O-ONBOARD-001 class). **Uncommitted (user commits):** ~29 `*.kt` (copy sweep + HomeScreen/HomeViewModel + OutcomeCheckInDialog + YourProgress + recovery + crypto visibility + androidTest) + `SECURITY.md` + `docs/Engineering_Reference_Manual.md` + `ClaudeReport.md`/`ClaudeQACoverage.md`/`ClaudeQAPlan.md`/`Future.md`. - **R20 (2026-06-29) — fresh full ClaudeQAPlan run from the start (user: "run the full ClaudeQAPlan") — found + FIXED 2 real escaped bugs (NOT a clean confirmation round).** Baseline: HEAD `62696a6` (R18b/R19 work committed; clean tree), both emulators paired + **free** (admin-confirmed), build reinstalled both. Cleared 1 stale ToT session by playing it through. **Cheap gates ALL GREEN:** unit **210** · functions **24** · `theme-scan` **CRITICAL 0** (9 MAJOR/23 REVIEW = intentional brand gradients) · `painter-xml-scan` **0** · `wiring-scan` **🔴0** · `entrypoint_smoke.sh` **6/6 on BOTH emulators (0 blocked)**. Discovery ritual: no drift (14 notif types + all fn triggers match coverage). **Cornerstones live-clean:** **A ✅** enforcement audit (every `isPremium`/`PremiumBadge` has a real `CouplePremiumChecker` gate — no badge-without-gate; A-201 class stays closed) + live both-free → Desire Sync → **Paywall** "Go deeper together" (graceful K-env "couldn't load plans", no crash). **B ✅** full 2-device This-or-That (QA joined via Home card → answered 10 → **first-finisher** → Sam got live **YOUR_TURN banner** → joined via banner → completion → **symmetric 5/10 "in sync" reveal** both devices). **D ✅** D1 at-rest `enc:v1:` (messages + lastMessagePreview + all 4 game answer-maps' per-uid values) · D2 rules static (Tier-2 self-constraint present, lines 361–374) · D3 non-member couple/messages/capsules/desire_sync reads **403** · D5 self-grant entitlement **403**. **E ✅** cold-start smoke 6/6 both + live YOUR_TURN + persistent RESULTS banners + `partner_completed_part` first-finisher push delivered. **0 FATAL across the whole live session.** **TWO BUGS FOUND + FIXED + VERIFIED LIVE:** **(1) B-ABANDON-001 (P2)** — Quit/abandon on ANY game silently failed `PERMISSION_DENIED`: `abandonSession` round-tripped through `saveSession` (a full `doc.set()`) which **drops the server-only flags** (`startNotifiedAt`/`joinNotifiedAt`/`partFinishNotifiedAt`); the session-update rule counts those removed keys in `affectedKeys()` → denied, so the session stayed `active` (stranded → blocks new games), failure swallowed by `Log.d`. Proven via logcat (`Write failed at .../sessions/…: PERMISSION_DENIED` → `quit-abandon no-op`). **Fix:** targeted `update(status, completedAt)` mirroring `markUserComplete` (`affectedKeys == {status, completedAt}` ⊆ allowlist) in `QuestionSessionRepositoryImpl.abandonSession`; routed the latent-twin dead method `GameSessionManager.finishGame` (0 callers) through it too. **Verified live:** Quit → no denial → `active=0`, then **started a different game immediately** (lockout resolved). **(2) B-COPY-001 (P3)** — Home "GAME_WAITING" hero hardcoded *"Your partner already played their part — take your turn to reveal"* but fires on `uid !in completedByUsers` only (for async games `completedByUsers` stays empty until BOTH finish), so it falsely claims the partner finished the instant a game is merely *started*. **Fix:** neutral, partner-named, always-accurate copy ("Game in progress / Pick up your game. / Jump back in to finish your picks and see how you and {name} line up.") — the accurate real-time "X played their part, your turn" nudge is still delivered by the push-driven YOUR_TURN banner. **Verified live both devices** (starter + joiner). Build + **210 unit + 24 functions green** after fixes. Remaining passes carry recent-round status (zero functional diff coming in; my fixes are HomeViewModel copy + session-completion writes only, no effect on C/F/G/H/I/J/L/M/N/P): **C** theme-scan CRIT 0; **L** chat at-rest `enc:v1:` (via D1); **K** money-path + **O** release + Doze = `blocked→needs-device`. **Verdict: R20 — cornerstones A/B/D/E live-clean, all cheap gates green, 0 FATAL; found + fixed B-ABANDON-001 (P2) + B-COPY-001 (P3) live. Board: 0 open P0/P1; 1 open P2 (O-AGE-001 pre-ship, user-blocked) + 1 P2 fixed-pending-confirm (B-ABANDON-001); 1 open P3 (BRAND-DARK-COVERAGE, user-blocked) + 1 P3 fixed-pending-confirm (B-COPY-001).** Uncommitted (user commits): `QuestionSessionRepositoryImpl.kt`, `GameSessionManager.kt`, `HomeViewModel.kt`, `ClaudeReport.md`, `ClaudeQACoverage.md`, `docs/Engineering_Reference_Manual.md`. **R20 follow-up (user: "make it so" on the instrumented smoke):** added the project's **first instrumented UI test** — `app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt`, an on-device Compose render smoke of the first-run crash composables (`CtaSlide` + `AuthLogoMark`, light+dark — the O-ONBOARD-001 `painterResource` sites). Infra: `testInstrumentationRunner` + `ui-test-junit4` in `build.gradle.kts`; exposed `CtaSlide` as `internal`; un-blocked the androidTest source set (the stale `CanonicalVectorCaptureInstrumentTest` couldn't compile against `private RecoveryKeyManager.deriveKey` → `@VisibleForTesting internal`). **Verified on emulator-5558 (API 34): 4/4 pass; PROVEN to catch the class** — reintroducing the `` foreground failed the test with the exact `IllegalArgumentException: Only VectorDrawables…` at `loadVectorResource`, then reverted → green. 210 unit + 24 functions still green. Wired into the QA-plan cheap gates (`./gradlew :app:connectedDebugAndroidTest` when an emulator is attached) + Future.md item marked started. Added files: `build.gradle.kts`, `RecoveryKeyManager.kt`, `OnboardingScreen.kt`, `app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt`, `ClaudeQAPlan.md`, `Future.md`. - **R19 (2026-06-28) — fresh full ClaudeQAPlan run from the start (user-directed, post-implementation).** Baseline reset clean: both emulators paired, both **free** (admin-confirmed premium=false), **0 active sessions** (cleared the one stale ToT by completing it 10/10). **Cheap gates ALL GREEN:** `./gradlew testDebugUnitTest` **210**, `cd functions && npm test` **24**, `scripts/theme-scan.sh` **CRITICAL 0** (11 MAJOR/25 REVIEW = intentional brand-purple/white in the wheel + banner + bubble colored-surface contexts), `scripts/painter-xml-scan.sh` **0**, `qa/entrypoint_smoke.sh` **6/6 on both emulators**. **Cornerstones live:** **A ✅** premium gate — both-free → Desire Sync → Paywall "Go deeper together" (graceful "couldn't load plans" K-env limit, no crash). **B ✅** This-or-That full 2-device → completed **10/10 "in sync"** reveal (+ this round's finish-gate / submit-retry / Quit-abandon). **D ✅** security cornerstone (raw-API): non-member couple/messages/capsules/desire_sync reads + self-grant entitlement **all DENIED 403**; messages at-rest `enc:v1:`; session/answer docs carry no plaintext. **E ✅** notifications: cold-start smoke 6/6 both + `partner_joined_game` live end-to-end (deployed) + standardized durable banner. **0 FATAL.** Remaining passes **carry their recent-round status** (per `ClaudeQACoverage.md`, code-stable — this round's diff is additive client + the deploy-gated rule): **C** theme-scan CRIT 0 + this-session dark spot-checks; **F** offline-cache (carried) + new Wheel submit-retry (unit-tested); **L** chat at-rest `enc:v1:` confirmed; **G/H/I/J/N/P** carried; **K** money-path + **O** release + Doze/battery = `blocked→needs-device` (pre-ship). **Verdict: R19 — cornerstones (A/B/D/E) live-clean, all cheap gates green, 0 FATAL; board 0 open P0/P1, 1 P2 (O-AGE-001 pre-ship), 1 P3 (BRAND-DARK-COVERAGE) — both user-blocked.** **Tier-2 rules DEPLOYED by user + VERIFIED LIVE (member-token raw-API):** own-uid add to `joinedByUsers` **ALLOWED 200** (legit path intact); foreign-uid add to `joinedByUsers`+`completedByUsers` **DENIED 403**; array removal **DENIED 403** — own-uid-only self-constraint holds without breaking markUserJoined/markUserComplete. (functions + rules all deployed; no remaining deploy gates.) - **R18b (2026-06-28) — polish + error-handling/security/consolidation hardening (user: "look for gaps, error handling, security, what else can we improve, can we consolidate").** Bundled with the modern-feel polish. **Error handling:** **E1 (P2 bug) FIXED** — `WheelSessionViewModel.submitAndFinish` used to swallow a submit failure and navigate to the reveal anyway (silent data loss + stuck session); now it surfaces a **retryable error** (`submitFailed`/`error` + a `SubmitErrorCard` "Retry", no false reveal), matching ToT/DesireSync/HowWell. **E3** — the silently-swallowed join-writes now `.onFailure { Log.w }` in all 4 games. **Consolidation (targeted):** deduped the 3 copies of the save-error string into `ui/games/GameCopy.SAVE_ANSWERS_ERROR` (used by all four); the per-game answer/reveal logic is intentionally NOT unified (premature abstraction). **Modern feel:** `GamePromptBanner` — soft **haptic on arrival** (reuses `CelebrationOverlay` `LocalHapticFeedback`), **spring overshoot** entrance, **live green presence dot** on the avatar for JOINED, **whole-card tap-to-act + swipe-up dismiss**, `liveRegion` + `contentDescription` a11y, and `GamePromptController` now **won't let a transient (started/joined) clobber a persistent (your-turn/results)** banner; **chat bubble** got the same arrival haptic for parity; warmer brand-voice copy (banner `styleFor` = client source of truth, cross-commented with the function). **Predictive back (2026):** `android:enableOnBackInvokedCallback="true"` (safe — 0 `onBackPressed`/`BackHandler` in the app). **Tier 3:** the Wheel active screen got a **"Quit game"** escape hatch (`abandon()`→`abandonSession`) so leaving mid-wheel no longer strands a session (mirrors ToT). **Verified:** build + **210 Android unit (+1 new Wheel submit-retry test) + 24 functions** green; **live (5554):** JOINED banner shows warm copy + green presence dot + real avatar; **Pass-E cold-start smoke 6/6 on BOTH emulators** (notification path regression-clean); 0 FATAL. **Tier 2 security — DEPLOYED + VERIFIED LIVE:** `firestore.rules` now constrains `completedByUsers`/`joinedByUsers` so a member can only add **their own** uid (no spoofing the partner's join/completion, no removals; `get(...,[])` tolerates old docs). Member-token raw-API test: own-uid add **200**, foreign-uid add (both arrays) **403**, removal **403**; markUserJoined/markUserComplete still pass. Uncommitted (user commits): `WheelSessionViewModel.kt`, `WheelSessionScreen.kt`, `WheelSessionViewModelTest.kt`, `GamePromptBanner.kt`, `GamePromptController.kt`, `MessageBubbleOverlay.kt`, `ThisOrThatScreen.kt`, `DesireSyncScreen.kt`, `HowWellScreen.kt`, `ui/games/GameCopy.kt`, `AndroidManifest.xml`, `firestore.rules`, `functions/src/games/onGameSessionUpdate.ts` (sender_name), `ClaudeReport.md`, `ClaudeQACoverage.md`. **NEXT: reset + full ClaudeQAPlan A→P fresh run (R19).** diff --git a/Future.md b/Future.md index a5ad2644..62d992da 100644 --- a/Future.md +++ b/Future.md @@ -53,6 +53,13 @@ Improvement & feature ideas surfaced while QA-testing as a consumer (each works ### Security hardening (defense-in-depth — not vulnerabilities; rules already hold) +> **Canonical security doc: [`SECURITY.md`](SECURITY.md)** (2026-06-29) — full threat model, what's +> protected vs. exposed (metadata), known caveats, and the **prioritized hardening roadmap**. The P0 +> there (**enforce App Check on the backend**, independent audit, release hardening), P1 (encrypt +> profile metadata, opt-in telemetry, recovery-phrase UX, biometric re-lock), and P2 (cert pinning, +> multi-device keys, data export, key rotation) are the authoritative list; the two items below are the +> older notes that fed into it. + - **Enforce App Check on Firestore (currently OFF).** Round 7 raw-API test: an authenticated request with **no App Check token** (raw Firestore REST) returned `200` for a member — so rules are the *sole* gate. Rules correctly deny non-members/cross-couple (all `403`), so this is not a live hole, but enabling App Check enforcement on Firestore diff --git a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt index 18e656e7..c762ed48 100644 --- a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt +++ b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt @@ -100,6 +100,10 @@ class CoupleEncryptionManager @Inject constructor( * Re-wraps the locally-held keyset with a new phrase and returns the new WrappedKey * so the caller can persist it to Firestore. The old phrase is NOT required. * Returns failure if no local keyset exists for [coupleId]. + * + * ⚠️ The new phrase only lives on THIS device. The partner's locally-stored phrase becomes stale + * (the phrase is never on the server in plaintext, so it can't propagate) — see the warning on + * [CoupleRepository.changeRecoveryPhrase]. Currently unwired; don't expose without partner re-share. */ suspend fun rewrapWithNewPhrase( coupleId: String, diff --git a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt index f1db98a2..967331db 100644 --- a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt @@ -47,6 +47,10 @@ class CoupleRepositoryImpl @Inject constructor( if (coupleId != null) encryptionManager.deleteKeyset(coupleId) } + // ⚠️ Unwired (no UI caller). Changing the phrase desyncs the PARTNER's locally-stored phrase — + // it's never on the server in plaintext, so the new phrase can't propagate to their device, and + // their "ask your partner" recovery copy would then be wrong. See CoupleRepository KDoc + SECURITY.md + // before exposing this. (TODO: if wired, also force the partner to re-save the new phrase.) override suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result = runCatching { val newWrapped = encryptionManager.rewrapWithNewPhrase(coupleId, newPhrase).getOrElse { throw it } coupleDataSource.updateWrappedKey(coupleId, newWrapped) diff --git a/app/src/main/java/app/closer/data/repository/DateIdeaSeed.kt b/app/src/main/java/app/closer/data/repository/DateIdeaSeed.kt index 21f5dbef..26635306 100644 --- a/app/src/main/java/app/closer/data/repository/DateIdeaSeed.kt +++ b/app/src/main/java/app/closer/data/repository/DateIdeaSeed.kt @@ -254,7 +254,7 @@ object DateIdeaSeed { DateIdea( id = "intimacy_question_deck", title = "Deeper question card night", - description = "Use a deck of intimacy prompts and answer them honestly over wine or tea.", + description = "Use a deck of intimacy questions and answer them honestly over wine or tea.", category = "intimacy", estimatedDuration = "1.5 hours", estimatedCost = DateCostLevel.FREE, diff --git a/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt b/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt index 1b4e0bbf..3b0bdaa3 100644 --- a/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt @@ -7,5 +7,18 @@ interface CoupleRepository { suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String, recoveryPhrase: String): Result suspend fun updateStreak(coupleId: String): Result suspend fun leaveCouple(userId: String): Result + + /** + * ⚠️ CURRENTLY UNWIRED — no UI calls this (verified 2026-06-29). Re-wraps the couple key under a + * NEW phrase and uploads a new `wrappedCoupleKey`, then re-saves the new phrase on THIS device only. + * + * DO NOT wire this to a UI without first solving partner re-sharing. The recovery phrase is the same + * for both partners and is never on the server in plaintext, so there is no way to push the new phrase + * to the partner's device. After a change, the partner's locally-stored phrase (and their Settings → + * Security reveal) is STALE — it no longer matches `wrappedCoupleKey`, which silently breaks the + * primary "lost your phrase? ask your partner" recovery path. If you expose a change-phrase feature, + * you MUST also force the partner to re-save the new phrase (e.g. re-share + re-confirm on their + * device). See SECURITY.md (recovery) and the Engineering Manual landmine "recovery-phrase change desync". + */ suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result } diff --git a/app/src/main/java/app/closer/ui/answers/AnswerHistoryScreen.kt b/app/src/main/java/app/closer/ui/answers/AnswerHistoryScreen.kt index 3e66e2bb..992736e3 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerHistoryScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerHistoryScreen.kt @@ -109,7 +109,7 @@ private fun AnswerHistoryContent( onDismissRequest = { pendingDelete = null }, title = { Text("Remove this answer?") }, text = { - Text("This removes the saved reflection from this device. The prompt itself will stay available.") + Text("This removes the saved answer from this device. The question itself will stay available.") }, confirmButton = { Button( @@ -159,7 +159,7 @@ private fun AnswerHistoryContent( color = MaterialTheme.colorScheme.onSurface ) Text( - text = "Private answers and revealed reflections, gathered in one place.", + text = "Private answers and shared reveals, gathered in one place.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -175,7 +175,7 @@ private fun AnswerHistoryContent( } val body = when { !state.isPairingLoaded -> "We are checking whether this private space is connected." - state.isPaired -> "Answer today's question or pick a prompt from a pack. Every answer you save will wait for you here." + state.isPaired -> "Answer today's question or pick one from a pack. Every answer you save will wait for you here." else -> "Invite your partner to unlock shared reveals and build your private answer history together." } val actionLabel = when { diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt index 48bf6968..5233249d 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt @@ -269,7 +269,7 @@ private fun NoAnswerState( Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { RevealPill("No answer yet") Text( - text = question?.text ?: "This prompt is ready when you are.", + text = question?.text ?: "This question is ready when you are.", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -828,7 +828,7 @@ private fun RevealHeader() { overflow = TextOverflow.Ellipsis ) Text( - text = "A saved answer can stay private, become a shared reflection, or simply wait for the right moment.", + text = "A saved answer can stay private, become a shared moment, or simply wait for the right time.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 4, diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt index b3ef40bf..1f4fa2d3 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch enum class FollowUpOption(val label: String, val route: String? = null) { - DEEPER_FOLLOW_UP("Want to ask one deeper follow-up?", AppRoute.QUESTION_COMPOSER), + DEEPER_FOLLOW_UP("Want to go one question deeper?", AppRoute.QUESTION_COMPOSER), DATE_IDEA("Want to turn this into a date idea?", AppRoute.DATE_BUILDER), SAVE_MEMORY("Want to save this as a memory?", AppRoute.MEMORY_LANE), ANOTHER_QUESTION("Want to try another question from this category?") diff --git a/app/src/main/java/app/closer/ui/components/OutcomeCheckInDialog.kt b/app/src/main/java/app/closer/ui/components/OutcomeCheckInDialog.kt index abdf4934..c83620a7 100644 --- a/app/src/main/java/app/closer/ui/components/OutcomeCheckInDialog.kt +++ b/app/src/main/java/app/closer/ui/components/OutcomeCheckInDialog.kt @@ -89,17 +89,17 @@ fun OutcomeCheckInDialog( onValueChange = { connection = it } ) OutcomeSlider( - label = "How well do you communicate?", + label = "How easy is it to talk lately?", value = communication, onValueChange = { communication = it } ) OutcomeSlider( - label = "How satisfied are you with intimacy?", + label = "How close do you feel physically?", value = intimacy, onValueChange = { intimacy = it } ) OutcomeSlider( - label = "How happy are you overall?", + label = "How happy do you feel together?", value = happiness, onValueChange = { happiness = it } ) @@ -126,7 +126,7 @@ fun OutcomeCheckInDialog( contentColor = SettingsOnPrimary ) ) { - Text("Submit", style = MaterialTheme.typography.labelLarge) + Text("Save", style = MaterialTheme.typography.labelLarge) } TextButton( diff --git a/app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt b/app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt index a6664bcb..3dc315b5 100644 --- a/app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt +++ b/app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt @@ -14,8 +14,8 @@ package app.closer.ui.home * 4. Reveal ready * 5. Partner answered, user pending * 6. Game waiting - * 7. Challenge waiting - * 8. Daily question unanswered + * 7. Daily question unanswered + * 8. Challenge waiting * 9. Weekly recap ready * 10. Capsule unlocked * 11. Date reminder @@ -58,8 +58,8 @@ object HomePriorityEngine { REVEAL_READY, PARTNER_ANSWERED_USER_PENDING, GAME_WAITING, - CHALLENGE_WAITING, DAILY_QUESTION_UNANSWERED, + CHALLENGE_WAITING, WEEKLY_RECAP_READY, CAPSULE_UNLOCKED, DATE_REMINDER, diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 4e3cb205..dd3dad4a 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -5,8 +5,12 @@ import app.closer.domain.model.Question import app.closer.domain.model.QuestionCategory import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Spacer -import androidx.compose.material3.Button import androidx.compose.ui.window.Dialog +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import app.closer.ui.components.CelebrationOverlay import app.closer.ui.components.CategoryGlyph import app.closer.ui.components.CloserActionButton @@ -28,7 +32,6 @@ import app.closer.ui.theme.closerCardColor import app.closer.ui.theme.isCloserDarkTheme import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -44,7 +47,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ButtonDefaults import app.closer.domain.model.OutcomeDay import app.closer.ui.components.OutcomeCheckInDialog import androidx.compose.material3.Icon @@ -128,8 +130,8 @@ fun HomeScreen( if (showBaselineDialog) { OutcomeCheckInDialog( - title = "Quick check-in", - subtitle = "Before you start, how are you feeling about your relationship right now?", + title = "A little check-in", + subtitle = "Before you dive in — how are things feeling between you two right now?", onDismiss = { viewModel.markBaselineOutcomeShown() showBaselineDialog = false @@ -300,36 +302,19 @@ private fun HomeContent( ) { HomeHeader( partnerName = state.partnerName, + partnerPhotoUrl = state.partnerPhotoUrl, streakCount = state.streakCount, isPaired = state.isPaired, unreadActivityCount = state.unreadActivityCount, - onTogether = { onNavigate(AppRoute.ACTIVITY) } + onTogether = { + onNavigate(if (state.isPaired) AppRoute.ACTIVITY else AppRoute.CREATE_INVITE) + } ) - if (state.isPaired) { - StreakCard( - streakCount = state.streakCount, - partnerName = state.partnerName, - onPartner = onPartner - ) - } - when { state.isLoading -> LoadingHomeCard() state.error != null -> ErrorHomeCard(message = state.error, onRefresh = onRefresh) else -> { - state.pendingActions.takeIf { it.isNotEmpty() }?.let { pending -> - WaitingForYouSection( - actions = pending, - partnerName = state.partnerName, - waitingGameType = state.waitingGameType, - onAction = onPendingActionSelected, - // The game-waiting hero joins the specific waiting game directly (not the - // generic Play hub the pending-card fallback would use). - onJoinGame = { onNavigate(state.waitingGameRoute ?: AppRoute.PLAY) } - ) - } - state.primaryAction?.let { action -> if (!state.isPaired && action.target == HomeActionTarget.InvitePartner) { PartnerActivationCard( @@ -339,8 +324,6 @@ private fun HomeContent( } else { PrimaryHomeActionCard( action = action, - stats = state.answerStats, - streakCount = state.streakCount, onAction = onActionSelected, onReminder = callbacks.onReminder, onReveal = callbacks.onReveal, @@ -351,6 +334,26 @@ private fun HomeContent( } } + if (state.isPaired) { + HomeStatusStrip( + streakCount = state.streakCount, + privateCount = state.answerStats.private, + partnerName = state.partnerName, + dailyQuestionState = state.dailyQuestionState, + onPartner = onPartner + ) + } + + state.pendingActions.takeIf { it.isNotEmpty() }?.let { pending -> + WaitingForYouSection( + actions = pending, + onAction = onPendingActionSelected, + // The game-waiting hero joins the specific waiting game directly (not the + // generic Play hub the pending-card fallback would use). + onJoinGame = { onNavigate(state.waitingGameRoute ?: AppRoute.PLAY) } + ) + } + ActionFeedSection( actions = state.secondaryActions, onAction = onActionSelected @@ -364,11 +367,6 @@ private fun HomeContent( ) } - if (state.secondaryActions.any { it.target == HomeActionTarget.QuestionPacks } || - state.primaryAction?.target != HomeActionTarget.QuestionPacks) { - MomentCueCard() - } - CategoryPreviewGrid( categories = state.categories, onCategory = onCategory, @@ -410,70 +408,97 @@ private fun HomeGlyphIcon( } @Composable -private fun StreakCard( +private fun HomeStatusStrip( streakCount: Int, + privateCount: Int, partnerName: String?, + dailyQuestionState: DailyQuestionState, onPartner: () -> Unit = {}, modifier: Modifier = Modifier ) { - val copy = when (streakCount) { - 0 -> "Your little ritual is waiting." - 1 -> "1 day showing up" - else -> "$streakCount days showing up" + val streakLabel = when (streakCount) { + 0 -> "Start streak" + 1 -> "1 night" + else -> "$streakCount nights" } - val partnerLine = if (streakCount > 0 && !partnerName.isNullOrBlank()) "with $partnerName" else null + val tonightLabel = when (dailyQuestionState) { + DailyQuestionState.UNANSWERED -> "Question ready" + DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "Answer saved" + DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "Your turn" + DailyQuestionState.BOTH_ANSWERED -> "Reveal ready" + DailyQuestionState.REVEALED -> "Revealed" + } + val partnerLabel = partnerName?.takeIf { it.isNotBlank() } ?: "Paired" - CloserCard( + Row( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(CloserRadii.FeatureCard), - containerColor = closerCardColor(alpha = 0.92f), - elevation = CloserElevations.Card + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatusChip( + icon = R.drawable.glyph_streak, + label = streakLabel, + modifier = Modifier.weight(1f) + ) + StatusChip( + icon = R.drawable.glyph_privacy_lock, + label = "Just for two", + modifier = Modifier.weight(1f), + secondaryLabel = "$privateCount saved" + ) + StatusChip( + icon = R.drawable.glyph_closer_heart_keyhole, + label = partnerLabel, + modifier = Modifier.weight(1f), + onClick = onPartner.takeIf { partnerName != null }, + secondaryLabel = tonightLabel + ) + } +} + +@Composable +private fun StatusChip( + @DrawableRes icon: Int, + label: String, + modifier: Modifier = Modifier, + secondaryLabel: String? = null, + onClick: (() -> Unit)? = null +) { + val chipModifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier + Surface( + modifier = chipModifier, + shape = RoundedCornerShape(CloserRadii.Tile), + color = closerCardColor(alpha = 0.78f), + tonalElevation = 0.dp ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), verticalAlignment = Alignment.CenterVertically ) { - Surface( - shape = RoundedCornerShape(CloserRadii.Tile), - color = CloserPalette.PinkSoft.copy(alpha = 0.28f), - modifier = Modifier.size(52.dp) + HomeGlyphIcon( + resId = icon, + contentDescription = null, + tint = CloserPalette.PinkAccentDeep, + modifier = Modifier.size(17.dp) + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(1.dp) ) { - Box(contentAlignment = Alignment.Center) { - HomeGlyphIcon( - resId = R.drawable.glyph_streak, - contentDescription = null, - tint = CloserPalette.PinkAccentDeep, - modifier = Modifier.size(28.dp) - ) - } - } - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - if (streakCount > 0) { - Text( - text = streakCount.toString(), - style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1 - ) - } Text( - text = copy, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = if (streakCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + text = label, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis ) - partnerLine?.let { + secondaryLabel?.let { Text( text = it, - style = MaterialTheme.typography.bodyMedium, - color = CloserPalette.PurpleDeep, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.clickable(onClick = onPartner) + overflow = TextOverflow.Ellipsis ) } } @@ -484,6 +509,7 @@ private fun StreakCard( @Composable private fun HomeHeader( partnerName: String?, + partnerPhotoUrl: String?, streakCount: Int, isPaired: Boolean, unreadActivityCount: Int = 0, @@ -506,31 +532,31 @@ private fun HomeHeader( if (streakCount > 0) { HomePill("$streakCount nights") } - // "Together" activity entry point with an unread badge. + // Partner/together entry point with an unread badge. Box { - Box( - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(999.dp)) - .background(CloserPalette.PurpleSoft) - .clickable(onClick = onTogether), - contentAlignment = Alignment.Center - ) { - HomeGlyphIcon( - resId = R.drawable.glyph_closer_heart_keyhole, - contentDescription = "Together activity", - tint = CloserPalette.PurpleRich, - modifier = Modifier.size(20.dp) - ) - } + PartnerHeaderBubble( + partnerName = partnerName, + partnerPhotoUrl = partnerPhotoUrl, + isPaired = isPaired, + onClick = onTogether + ) if (unreadActivityCount > 0) { + // Surface-ringed dot so it stays legible over the avatar photo or the gradient ring. Box( modifier = Modifier .align(Alignment.TopEnd) - .size(11.dp) - .clip(RoundedCornerShape(999.dp)) - .background(CloserPalette.PinkBright) - ) + .size(14.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(CloserPalette.PinkBright) + ) + } } } } @@ -538,7 +564,7 @@ private fun HomeHeader( text = if (!isPaired) "Set up your shared space, then keep exploring at your own pace." else if (partnerName != null) - "Connected with $partnerName. One clear next step, then the rest can stay quiet." + "Connected with $partnerName. Here's what matters tonight." else "Open the app, see what matters, and take one small step toward closeness.", style = MaterialTheme.typography.bodyLarge, @@ -549,6 +575,95 @@ private fun HomeHeader( } } +@Composable +private fun PartnerHeaderBubble( + partnerName: String?, + partnerPhotoUrl: String?, + isPaired: Boolean, + onClick: () -> Unit +) { + // Brand gradient ring around the avatar — signals "your partner" + that it's tappable. + val ringBrush = Brush.linearGradient( + listOf(CloserPalette.PurpleRich, CloserPalette.PinkBright) + ) + val label = when { + !isPaired -> "Invite your partner" + !partnerName.isNullOrBlank() -> "Open $partnerName's space" + else -> "Open your shared space" + } + + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(ringBrush) + .clickable(onClick = onClick) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(46.dp) + .clip(CircleShape) + .background(CloserPalette.PurpleSoft), + contentAlignment = Alignment.Center + ) { + when { + !isPaired -> HomeGlyphIcon( + resId = R.drawable.glyph_couple, + contentDescription = null, + tint = CloserPalette.PurpleRich, + modifier = Modifier.size(24.dp) + ) + !partnerPhotoUrl.isNullOrBlank() -> SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(partnerPhotoUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(CircleShape), + // While loading or on failure, keep the partner's initials centered in view + // (never a blank/grey circle). + loading = { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + PartnerInitials(partnerName = partnerName) + } + }, + error = { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + PartnerInitials(partnerName = partnerName) + } + } + ) + else -> PartnerInitials(partnerName = partnerName) + } + } + } +} + +@Composable +private fun PartnerInitials(partnerName: String?) { + Text( + text = partnerInitials(partnerName), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = CloserPalette.PurpleRich, + maxLines = 1 + ) +} + +private fun partnerInitials(name: String?): String { + val clean = name?.trim().orEmpty() + if (clean.isBlank()) return "2" + val words = clean.split(Regex("\\s+")).filter { it.isNotBlank() } + val initials = if (words.size >= 2) { + "${words[0].first()}${words[1].first()}" + } else { + clean.take(2) + } + return initials.uppercase() +} + @Composable private fun PartnerActivationCard( onInvite: () -> Unit, @@ -706,8 +821,6 @@ private fun ActivationBenefitPill( @Composable private fun PrimaryHomeActionCard( action: HomeAction, - stats: HomeAnswerStats, - streakCount: Int, onAction: (HomeAction) -> Unit, onReminder: () -> Unit, onReveal: () -> Unit, @@ -717,9 +830,7 @@ private fun PrimaryHomeActionCard( ) { val colors = action.tone.actionColors() val isDark = isCloserDarkTheme() - val showTonightPartnerArt = action.target == HomeActionTarget.DailyQuestion && - (dailyQuestionState == DailyQuestionState.UNANSWERED || - dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING) + val artRes = homePrimaryArt(action.target) // For daily-question actions, route the CTA through the explicit state handlers // so the same button label maps to the correct next step (answer, remind, @@ -815,27 +926,19 @@ private fun PrimaryHomeActionCard( } } - if (showTonightPartnerArt) { + artRes?.let { res -> BrandIllustration( - res = R.drawable.illustration_tonight_partner_prompt, + res = res, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() - .height(148.dp) + .height(150.dp) .clip(RoundedCornerShape(22.dp)) ) } - if (action.target == HomeActionTarget.DailyQuestion && !showTonightPartnerArt) { - Text( - text = "Got 5 min?", - style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - if (showTonightPartnerArt) { + if (artRes != null) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = titleOverride, @@ -894,8 +997,6 @@ private fun PrimaryHomeActionCard( } } - HomePulseStrip(stats = stats, streakCount = streakCount) - CloserActionButton( label = action.cta, onClick = ctaClick, @@ -907,63 +1008,12 @@ private fun PrimaryHomeActionCard( } } -@Composable -private fun HomePulseStrip( - stats: HomeAnswerStats, - streakCount: Int -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - PulseMetric( - label = "Saved", - value = stats.total.toString(), - modifier = Modifier.weight(1f) - ) - PulseMetric( - label = "Private", - value = stats.private.toString(), - modifier = Modifier.weight(1f) - ) - PulseMetric( - label = "Nights", - value = streakCount.toString(), - modifier = Modifier.weight(1f) - ) - } -} - -@Composable -private fun PulseMetric( - label: String, - value: String, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier, - shape = RoundedCornerShape(CloserRadii.Tile), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.66f) - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = value, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.primary, - maxLines = 1 - ) - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } +@DrawableRes +private fun homePrimaryArt(target: HomeActionTarget): Int? = when (target) { + HomeActionTarget.DailyQuestion, + HomeActionTarget.AnswerReveal -> R.drawable.illustration_home_tonight_ritual + HomeActionTarget.Challenge -> R.drawable.illustration_connection_challenges_header + else -> null } @Composable @@ -975,7 +1025,7 @@ private fun ActionFeedSection( Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( - text = "After that", + text = "More ways to connect", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface ) @@ -1265,7 +1315,7 @@ private fun CategoryMiniCard( overflow = TextOverflow.Ellipsis ) Text( - text = "${item.questionCount} prompts", + text = "${item.questionCount} questions", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -1278,110 +1328,21 @@ private fun CategoryMiniCard( @Composable private fun WaitingForYouSection( actions: List, - partnerName: String?, - waitingGameType: String?, onAction: (PendingActionCard) -> Unit, onJoinGame: () -> Unit ) { - val gameCard = actions.firstOrNull { it.target == HomeActionTarget.Game } - val others = actions.filter { it.target != HomeActionTarget.Game } Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( - text = "Waiting for you", + text = "Also waiting", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface ) - // The game-waiting prompt gets a bold hero treatment (promoted to the top) and joins the - // specific waiting game directly. - if (gameCard != null) { - GameWaitingHeroCard( - partnerName = partnerName, - gameName = gameDisplayName(waitingGameType), - onJoin = onJoinGame - ) - } - others.take(3).forEach { card -> - PendingActionCardView(card = card, onClick = { onAction(card) }) - } - } -} - -private fun gameDisplayName(gameType: String?): String = when (gameType) { - "wheel" -> "Spin the Wheel" - "this_or_that" -> "This or That" - "how_well" -> "How Well Do You Know Me" - "desire_sync" -> "Desire Sync" - else -> "a game together" -} - -/** Bold, eye-catching "your partner is waiting to play " hero with a direct Join CTA. */ -@Composable -private fun GameWaitingHeroCard( - partnerName: String?, - gameName: String, - onJoin: () -> Unit -) { - val name = partnerName?.takeIf { it.isNotBlank() } ?: "Your partner" - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(CloserRadii.Card)) - .background( - Brush.linearGradient(listOf(CloserPalette.PurpleDeep, CloserPalette.PurpleRich)) - ) - .padding(18.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.18f)) - .border(1.5.dp, Color.White.copy(alpha = 0.5f), CircleShape), - contentAlignment = Alignment.Center - ) { - HomeGlyphIcon( - resId = R.drawable.glyph_play, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = "$name is waiting to play", - style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.85f) - ) - Text( - text = gameName, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = Color.White - ) - } - } - Button( - onClick = onJoin, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = CloserPalette.PurpleDeep - ), - shape = RoundedCornerShape(CloserRadii.Button) - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.glyph_play), - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Text( - text = "Join the game", - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), - modifier = Modifier.padding(start = 6.dp) + actions.take(3).forEach { card -> + PendingActionCardView( + card = card, + onClick = { + if (card.target == HomeActionTarget.Game) onJoinGame() else onAction(card) + } ) } } @@ -1613,7 +1574,7 @@ fun PairedHomePreviewScreen(onNavigate: (String) -> Unit = {}) { HomeAction( eyebrow = "Keep playing", title = "Question packs", - body = "Fresh prompts for the two of you.", + body = "Fresh questions for the two of you.", cta = "Browse packs", target = HomeActionTarget.QuestionPacks, tone = HomeActionTone.Pack diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 5b38ed85..3516e2fe 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -132,6 +132,7 @@ data class HomeUiState( val categories: List = emptyList(), val answerStats: HomeAnswerStats = HomeAnswerStats(), val partnerName: String? = null, + val partnerPhotoUrl: String? = null, val streakCount: Int = 0, val isPaired: Boolean = false, val primaryAction: HomeAction? = null, @@ -236,11 +237,13 @@ class HomeViewModel @Inject constructor( val uid = authRepository.currentUserId uid?.let { launch { runCatching { sealedRevealManager.ensurePublicKeyPublished(it) } } } val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() } - val partnerName = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId -> - runCatching { userRepository.getUser(partnerId)?.displayName } - .onFailure { Log.w(TAG, "Could not load partner display name", it) } + val partnerUser = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId -> + runCatching { userRepository.getUser(partnerId) } + .onFailure { Log.w(TAG, "Could not load partner profile", it) } .getOrNull() } + val partnerName = partnerUser?.displayName + val partnerPhotoUrl = partnerUser?.photoUrl val encryptionStatus = couple?.let(encryptionManager::checkStatus) val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY @@ -318,6 +321,7 @@ class HomeViewModel @Inject constructor( dailyQuestion = dailyQuestion, categories = categories, partnerName = partnerName, + partnerPhotoUrl = partnerPhotoUrl, streakCount = couple?.streakCount ?: 0, isPaired = couple != null, coupleId = coupleId, @@ -575,7 +579,12 @@ class HomeViewModel @Inject constructor( val secondary = priorityOutput.secondary.mapNotNull { toHomeAction(it.priority) } // 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 } + val pending = buildPendingActions().filterNot { pending -> + pending.target == primary?.target || + (primary?.target == HomeActionTarget.DailyQuestion && + (pending.target == HomeActionTarget.AnswerReveal || + pending.target == HomeActionTarget.DailyQuestion)) + } return copy( primaryAction = primary, @@ -643,10 +652,10 @@ class HomeViewModel @Inject constructor( ) Priority.CHALLENGE_WAITING -> HomeAction( - eyebrow = "Challenge waiting", - title = "Today’s small step is ready.", - body = "Your connection challenge is waiting for both of you. Show up together tonight.", - cta = "View challenge", + eyebrow = "Connection challenge", + title = "Today’s challenge step is ready.", + body = "Open one small shared action for tonight. It is meant to feel doable, not like homework.", + cta = "Open challenge", target = HomeActionTarget.Challenge, tone = HomeActionTone.Ritual ) @@ -688,7 +697,7 @@ class HomeViewModel @Inject constructor( HomeAction( eyebrow = "Suggested pack", title = category.category.displayName.ifBlank { "Question pack" }, - body = "${category.questionCount} prompts for when you want a different doorway into the conversation.", + body = "${category.questionCount} questions for when you want a different doorway into the conversation.", cta = "Open pack", target = HomeActionTarget.QuestionPacks, tone = HomeActionTone.Pack, @@ -711,7 +720,7 @@ class HomeViewModel @Inject constructor( body: String, cta: String ): HomeAction = HomeAction( - eyebrow = "Tonight's prompt", + eyebrow = "Your daily question", title = title, body = body, cta = cta, diff --git a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt index 3c09b6d1..b4420396 100644 --- a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt +++ b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt @@ -688,7 +688,7 @@ private fun CapsuleCreateScreen( // Optional prompt selector Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Need a prompt?", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("Need an idea?", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { capsulePrompts.forEach { prompt -> val selected = state.selectedPrompt == prompt diff --git a/app/src/main/java/app/closer/ui/outcomes/YourProgressScreen.kt b/app/src/main/java/app/closer/ui/outcomes/YourProgressScreen.kt index a087d1fd..f44721d9 100644 --- a/app/src/main/java/app/closer/ui/outcomes/YourProgressScreen.kt +++ b/app/src/main/java/app/closer/ui/outcomes/YourProgressScreen.kt @@ -63,7 +63,7 @@ fun YourProgressScreen( modifier = Modifier.background(SettingsBackgroundBrush), topBar = { TopAppBar( - title = { Text("Your Progress", color = SettingsInk) }, + title = { Text("Growing together", color = SettingsInk) }, navigationIcon = { IconButton(onClick = onBack) { Icon( @@ -126,9 +126,9 @@ fun YourProgressScreen( @Composable private fun ProgressHeader(baseline: Outcome?) { val subtitle = if (baseline == null) { - "Submit your first check-in to start tracking how your relationship feels over time." + "Share your first check-in to see how things feel between you two over time." } else { - "You’ve started tracking how your relationship feels. Check back at 30, 60, and 90 days to see what changes." + "You’ve shared your first check-in. Come back at 30, 60, and 90 days to see how things have grown." } Card( @@ -151,7 +151,7 @@ private fun ProgressHeader(baseline: Outcome?) { ) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = if (baseline == null) "No baseline yet" else "Baseline recorded", + text = if (baseline == null) "No check-ins yet" else "First check-in saved", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = SettingsInk @@ -191,7 +191,7 @@ private fun DeltaCard(baseline: Outcome, latest: Outcome) { verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = "Change since baseline", + text = "Since you started", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = SettingsInk @@ -249,10 +249,10 @@ private fun MilestoneList(outcomes: List) { @Composable private fun MilestoneRow(day: OutcomeDay, outcome: Outcome?) { val (title, status) = when (day) { - OutcomeDay.BASELINE -> "Baseline" to if (outcome != null) "Recorded" else "Not yet" - OutcomeDay.DAY_30 -> "30-day check-in" to if (outcome != null) "Recorded" else "Not yet" - OutcomeDay.DAY_60 -> "60-day check-in" to if (outcome != null) "Recorded" else "Not yet" - OutcomeDay.DAY_90 -> "90-day check-in" to if (outcome != null) "Recorded" else "Not yet" + OutcomeDay.BASELINE -> "Where you started" to if (outcome != null) "Saved" else "Not yet" + OutcomeDay.DAY_30 -> "30 days in" to if (outcome != null) "Saved" else "Not yet" + OutcomeDay.DAY_60 -> "60 days in" to if (outcome != null) "Saved" else "Not yet" + OutcomeDay.DAY_90 -> "90 days in" to if (outcome != null) "Saved" else "Not yet" } Row( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/app/closer/ui/outcomes/YourProgressViewModel.kt b/app/src/main/java/app/closer/ui/outcomes/YourProgressViewModel.kt index 686c943f..017af227 100644 --- a/app/src/main/java/app/closer/ui/outcomes/YourProgressViewModel.kt +++ b/app/src/main/java/app/closer/ui/outcomes/YourProgressViewModel.kt @@ -100,7 +100,7 @@ class YourProgressViewModel @Inject constructor( _uiState.update { it.copy(submitSuccess = true) } load() } catch (e: Exception) { - _uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn’t submit check-in.") } + _uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn’t save your check-in.") } } } } diff --git a/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt b/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt index 956e3f99..087e4b6d 100644 --- a/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt @@ -270,7 +270,7 @@ fun CreateInviteScreen( fontWeight = FontWeight.Medium ) Text( - "Write this down and share it with your partner. You'll both need it to access your answers on a new phone.", + "Write it down and share it with your partner — you'll both have the same phrase, and either of you can use it (or read it to the other) to restore your history on a new phone. Lose it on every device and it can't be recovered.", style = MaterialTheme.typography.bodySmall, color = SettingsMuted ) diff --git a/app/src/main/java/app/closer/ui/pairing/RecoveryScreen.kt b/app/src/main/java/app/closer/ui/pairing/RecoveryScreen.kt index 2bb68c2d..fda8c0c2 100644 --- a/app/src/main/java/app/closer/ui/pairing/RecoveryScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/RecoveryScreen.kt @@ -149,7 +149,16 @@ fun RecoveryScreen( Spacer(Modifier.height(20.dp)) Text( - "If you've lost your phrase and don't have another device, your encrypted history cannot be recovered — this is by design.", + "Don't have your phrase? Ask your partner — they were shown the same one and can reveal it any time in Settings → Security.", + style = MaterialTheme.typography.bodyMedium, + color = SettingsInk, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(12.dp)) + + Text( + "If neither of you has the phrase and you have no other signed-in device, your encrypted history can't be recovered — this is by design (not even we can read it).", style = MaterialTheme.typography.bodySmall, color = SettingsMuted, textAlign = TextAlign.Center diff --git a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt index 84dde75d..fc53d0f4 100644 --- a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt +++ b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt @@ -73,7 +73,7 @@ private val BENEFITS = listOf( "Unlimited questions every day", "Every premium question pack", "Date planning and bucket list", - "Full answer history and insights", + "Full answer history and growth", "Custom questions and private notes", "Exportable memories" ) diff --git a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt index 052961b7..3fc5709b 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt @@ -149,7 +149,7 @@ private fun PlayHubContent( item { CompactPlayCard( title = "Question Packs", - subtitle = "Themed prompts to explore together", + subtitle = "Themed questions to explore together", icon = ImageVector.vectorResource(R.drawable.glyph_question_packs), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxWidth(), @@ -260,7 +260,7 @@ private fun ThisOrThatCard( overflow = TextOverflow.Ellipsis ) CloserPill( - label = "10 prompts", + label = "10 questions", containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.78f), contentColor = MaterialTheme.colorScheme.onSecondaryContainer ) @@ -647,7 +647,7 @@ private fun FeaturedPlayCard( contentColor = MaterialTheme.colorScheme.onSurface ) CloserPill( - label = "10 prompts", + label = "10 questions", containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer ) diff --git a/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt b/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt index 63ce5998..49b7f9d2 100644 --- a/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt +++ b/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt @@ -110,7 +110,7 @@ fun LocalQuestionContent( ) state.question == null -> EmptyState( title = "This one's not available", - body = "Try a different prompt — there are plenty more in your packs." + body = "Try a different question — there are plenty more in your packs." ) else -> { val question = state.question diff --git a/app/src/main/java/app/closer/ui/questions/QuestionCategoryScreen.kt b/app/src/main/java/app/closer/ui/questions/QuestionCategoryScreen.kt index b902233b..2b652150 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionCategoryScreen.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionCategoryScreen.kt @@ -157,7 +157,7 @@ private fun QuestionCategoryContent( ) ) { Text( - text = if (state.questions.isEmpty()) "No prompts yet" else "Pick a prompt", + text = if (state.questions.isEmpty()) "No questions yet" else "Pick a question", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold ) @@ -205,7 +205,7 @@ private fun CategoryHero( overflow = TextOverflow.Ellipsis ) Text( - text = category?.description ?: "Prompts for this kind of conversation.", + text = category?.description ?: "Questions for this kind of conversation.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 4, @@ -220,7 +220,7 @@ private fun CategoryHero( .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - CategoryPill("$questionCount ${if (questionCount == 1) "prompt" else "prompts"}", emphasis = true) + CategoryPill("$questionCount ${if (questionCount == 1) "question" else "questions"}", emphasis = true) category?.access?.let { access -> CategoryPill( when (access) { @@ -314,7 +314,7 @@ private fun CategoryLoadingCard() { ) { CloserHeartLoader(size = 32.dp) Text( - text = "Loading prompts", + text = "Loading questions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -364,7 +364,7 @@ fun QuestionCategoryScreenPreview() { category = QuestionCategory( id = "emotional_intimacy", displayName = "Emotional Intimacy", - description = "Prompts for closeness, tenderness, and reassurance.", + description = "Questions for closeness, tenderness, and reassurance.", access = "mixed", iconName = "heart" ), diff --git a/app/src/main/java/app/closer/ui/questions/QuestionComposerScreen.kt b/app/src/main/java/app/closer/ui/questions/QuestionComposerScreen.kt index a94a94d0..a1210cb3 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionComposerScreen.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionComposerScreen.kt @@ -14,15 +14,15 @@ fun QuestionComposerScreen( FinishedEmptyStateScreen( eyebrow = "Questions", title = "Create a question with care", - body = "Custom prompts are coming soon. For now, start from a pack so the tone stays clear, generous, and easy to answer.", + body = "Custom questions are coming soon. For now, start from a pack so the tone stays clear, generous, and easy to answer.", glyphCategoryId = "question", primaryAction = FinishedEmptyStateAction("Browse packs", AppRoute.QUESTION_PACKS), secondaryAction = FinishedEmptyStateAction("Daily question", AppRoute.DAILY_QUESTION), accent = MaterialTheme.colorScheme.primary, details = listOf( - "Choose prompts already shaped for real conversation.", + "Choose questions already shaped for real conversation.", "Keep the next step focused on answering, not drafting.", - "Return here when saved custom prompts are ready." + "Return here when saved custom questions are ready." ), onNavigate = onNavigate ) diff --git a/app/src/main/java/app/closer/ui/questions/QuestionPackLibraryScreen.kt b/app/src/main/java/app/closer/ui/questions/QuestionPackLibraryScreen.kt index 569baef9..595cad9e 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionPackLibraryScreen.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionPackLibraryScreen.kt @@ -358,7 +358,7 @@ private fun PackPill( } private fun QuestionPackItem.promptCountLabel(): String = - "$questionCount ${if (questionCount == 1) "prompt" else "prompts"}" + "$questionCount ${if (questionCount == 1) "question" else "questions"}" private fun QuestionPackItem.metadataLabels(): List { val access = when (category.access) { @@ -477,7 +477,7 @@ fun QuestionPackLibraryScreenPreview() { category = QuestionCategory( id = "emotional_intimacy", displayName = "Emotional Intimacy", - description = "Prompts for closeness, reassurance, and being known.", + description = "Questions for closeness, reassurance, and being known.", access = "mixed", iconName = "heart" ), diff --git a/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt b/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt index 71efa370..ca5613f2 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt @@ -270,7 +270,7 @@ private fun RevealedPhase( contentColor = MaterialTheme.colorScheme.onPrimary ) ) { - Text("Next prompt") + Text("Next question") } } OutlinedButton( @@ -296,7 +296,7 @@ private fun RevealedPhase( modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), shape = RoundedCornerShape(16.dp) ) { - Text("Previous prompt") + Text("Previous question") } } } diff --git a/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt b/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt index 5f3f4a34..16f33489 100644 --- a/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt @@ -152,7 +152,7 @@ fun SecurityScreen( text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - "Write this down and keep it somewhere safe. You'll need it to restore your encrypted history if both devices are lost.", + "This is your couple's recovery phrase — your partner has the same one. Keep it somewhere safe; either of you can use it to restore your encrypted history on a new device. (If your partner ever loses theirs, you can read them this.)", style = MaterialTheme.typography.bodySmall, color = SettingsMuted ) diff --git a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt index 8fa346a3..9594ee46 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt @@ -247,8 +247,8 @@ fun SettingsScreen( if (showBaselineOutcomeDialog) { OutcomeCheckInDialog( - title = "Quick check-in", - subtitle = "Before you start, how are you feeling about your relationship right now?", + title = "A little check-in", + subtitle = "Before you dive in — how are things feeling between you two right now?", onDismiss = { viewModel.markBaselineOutcomeShown() showBaselineOutcomeDialog = false @@ -482,8 +482,8 @@ fun SettingsScreen( SettingsSectionDivider() SettingsRow( icon = CloserGlyphs.TrendingUp, - label = "Your Progress", - subtitle = "See patterns, check-ins, and growth", + label = "Growing together", + subtitle = "Look back at your check-ins and how you've grown", onClick = { onNavigate(AppRoute.YOUR_PROGRESS) } ) } diff --git a/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt b/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt index 5d23d366..3c82b9e5 100644 --- a/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt @@ -72,7 +72,7 @@ import app.closer.ui.components.CloserGlyphs private val BENEFITS = listOf( "Unlimited questions every day", "Every premium question pack", - "Full answer history and insights", + "Full answer history and growth", "Date planning and bucket list", "Connection Challenges and Desire Sync", "Memory Lane time capsules", diff --git a/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt b/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt index 16136a8d..0994bfd9 100644 --- a/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt @@ -222,14 +222,14 @@ private fun WheelPickerHeader( verticalArrangement = Arrangement.spacedBy(3.dp) ) { Text( - text = "Ten prompts per spin", + text = "Ten questions per spin", style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( - text = "Skip and come back to any prompt — you'll just answer them all before the reveal.", + text = "Skip and come back to any question — you'll just answer them all before the reveal.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, @@ -284,7 +284,7 @@ private fun CategoryCard( overflow = TextOverflow.Ellipsis ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - CategoryPill("${item.questionCount} prompts") + CategoryPill("${item.questionCount} questions") if (item.isLocked) CategoryPill("Premium") } } @@ -324,12 +324,12 @@ fun CategoryPickerScreenPreview() { isLoading = false, categories = listOf( CategoryPickerItem( - category = QuestionCategory("communication", "Communication", "Prompts for connection", "free", "chat"), + category = QuestionCategory("communication", "Communication", "Questions for connection", "free", "chat"), questionCount = 250, isLocked = false ), CategoryPickerItem( - category = QuestionCategory("intimacy", "Intimacy", "Prompts for closeness", "premium", "heart"), + category = QuestionCategory("intimacy", "Intimacy", "Questions for closeness", "premium", "heart"), questionCount = 180, isLocked = true ) diff --git a/app/src/main/java/app/closer/ui/wheel/SpinWheelScreen.kt b/app/src/main/java/app/closer/ui/wheel/SpinWheelScreen.kt index 469da087..fa756bd6 100644 --- a/app/src/main/java/app/closer/ui/wheel/SpinWheelScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/SpinWheelScreen.kt @@ -145,7 +145,7 @@ private fun SpinWheelContent( verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - text = "Let the prompt find you", + text = "Let the question find you", style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, diff --git a/app/src/main/res/drawable-night-nodpi/illustration_home_tonight_ritual.png b/app/src/main/res/drawable-night-nodpi/illustration_home_tonight_ritual.png new file mode 100644 index 00000000..873d3f90 Binary files /dev/null and b/app/src/main/res/drawable-night-nodpi/illustration_home_tonight_ritual.png differ diff --git a/app/src/main/res/drawable-nodpi/illustration_home_tonight_ritual.png b/app/src/main/res/drawable-nodpi/illustration_home_tonight_ritual.png new file mode 100644 index 00000000..1c03d6a6 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/illustration_home_tonight_ritual.png differ diff --git a/app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt b/app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt index a1e6f8e5..9c875e00 100644 --- a/app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt +++ b/app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt @@ -94,7 +94,7 @@ class HomePriorityEngineTest { } @Test - fun `challenge waiting outranks daily question`() { + fun `daily question anchors home before challenge waiting`() { val input = Input( isPaired = true, challengeWaiting = true, @@ -103,7 +103,11 @@ class HomePriorityEngineTest { val output = HomePriorityEngine.compute(input) - assertEquals(Priority.CHALLENGE_WAITING, output.primary?.priority) + assertEquals(Priority.DAILY_QUESTION_UNANSWERED, output.primary?.priority) + assertEquals( + listOf(Priority.CHALLENGE_WAITING), + output.secondary.map { it.priority } + ) } @Test @@ -214,8 +218,8 @@ class HomePriorityEngineTest { assertEquals( listOf( Priority.GAME_WAITING, - Priority.CHALLENGE_WAITING, - Priority.DAILY_QUESTION_UNANSWERED + Priority.DAILY_QUESTION_UNANSWERED, + Priority.CHALLENGE_WAITING ), output.secondary.map { it.priority } ) diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 76efa425..bb342242 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -307,7 +307,7 @@ The recovery phrase is the only human-readable secret in the system. It is never 2. The inviter encrypts the phrase with the invite code using `encryptPhraseWithCode` and stores the blob on the invite document. 3. The acceptor receives the encrypted blob, decrypts it with the same code, and stores the phrase locally. 4. The phrase is used to unwrap the couple keyset from `wrappedCoupleKey`. -5. The recovery phrase can be shown in settings and used to recover the couple key on a new device. Changing the recovery phrase re-wraps the locally-held keyset and uploads a new `wrappedCoupleKey` to Firestore. +5. The recovery phrase can be shown in settings and used to recover the couple key on a new device. **Both partners hold the same phrase** (the acceptor stores it in step 3), so either partner can re-reveal it in Settings → Security and supply it to the other — the partner is a built-in backup, and the new-device Recovery screen guides the user to "ask your partner." Changing the recovery phrase re-wraps the locally-held keyset and uploads a new `wrappedCoupleKey` to Firestore — **but it desyncs the partner's stored copy**; see the landmine [Recovery-phrase change desync](#recovery-phrase-change-desync--changing-the-couple-phrase-silently-breaks-the-partners-ask-your-partner-recovery). The change-phrase path is currently UNWIRED. iOS does not generate or store a recovery phrase in the current build. iOS couples have no recovery path; the couple key (when iOS E2EE ships) will need a different recovery story or the gap will need to be communicated to users. @@ -1169,6 +1169,11 @@ These are bugs that cost real debugging time and are easy to re-introduce if you **Fix (R20)**: `abandonSession` now does a targeted `firestore...document(activeSession.id).update(mapOf("status" to "completed","completedAt" to now))` so `affectedKeys == {status, completedAt}` ⊆ the allowlist (and the active→completed transition satisfies the monotonic-status clause); `finishGame` delegates to it. Verified live: Quit → no denial → session `active=0` → a different game starts immediately (lockout gone). **Re-introduction risk**: **`saveSession()` is a CREATE primitive (full `doc.set`), not an UPDATE primitive.** Any code that mutates an *existing* session must use a targeted `update(...)` touching ONLY allowlisted keys (`status`/`completedAt`/`completedByUsers`/`joinedByUsers`) — a full `set()` silently strips server-written fields and the rule denies it. The hazard is invisible in unit tests (no rules) and is swallowed by best-effort `Log.d` callers, so **any new session-completion/abandon path must be exercised live against deployed rules** (watch logcat for `PERMISSION_DENIED` on the session doc), not just compiled. When you add a new server-only session field, it is dropped by `saveSession` for the same reason — prefer `update()` everywhere a session already exists. +### Recovery-phrase change desync — changing the couple phrase silently breaks the partner's "ask your partner" recovery +**What**: the recovery phrase is a single **per-couple** secret that **both partners** receive at pairing and store locally (Keystore-backed `RecoveryPhraseStore` / `CoupleKeyStore`); it is never on the server in plaintext. This is by design and is a *feature* — it makes the partner a built-in backup: a user on a new device can recover by asking their partner to read them the phrase (Settings → Security), and the Recovery screen now tells them to. **The trap:** `CoupleRepository.changeRecoveryPhrase` → `CoupleEncryptionManager.rewrapWithNewPhrase` re-wraps the key under a new phrase and uploads a new `wrappedCoupleKey`, but only re-saves the new phrase on the **changer's** device. There is no channel to push the new phrase to the partner's device, so the partner's stored phrase (and their Settings → Security reveal) goes **stale** — it no longer matches `wrappedCoupleKey`, silently breaking the primary recovery path. +**Status (2026-06-29)**: the change-phrase API is **UNWIRED** — no UI caller — so the desync is **not user-reachable today**. It now carries a loud warning at all three layers (`CoupleRepository.changeRecoveryPhrase` KDoc, the impl comment, `rewrapWithNewPhrase` KDoc). +**Re-introduction risk**: do **not** wire a "change recovery phrase" UI without first solving partner re-sharing (force the partner to re-save the new phrase — e.g. re-share + re-confirm on their device — or treat the phrase as a fixed pairing secret that can't be changed). Otherwise a user who changed the phrase and then lost it would be unrecoverable, and "ask your partner" would hand over a wrong phrase. See [SECURITY.md](../SECURITY.md) (recovery section). + ### N-001 / N-002 — VMs that wait for the screen to push an id silently no-op if nothing pushes it **Symptom (R15)**: the **Bucket List was entirely non-functional** — add/load/complete/delete all did nothing, no error, no logcat. **Root cause**: `BucketListViewModel` gated every operation on `if (coupleId.isEmpty()) return`, expecting the screen to call `setCoupleId(...)` — but `BucketListScreen` never did (the nav route passes no coupleId and there's no `LaunchedEffect`). So `coupleId` stayed `""` and every op returned early **silently**. Same class hit **Date Builder (N-002)**: `savePreference()` bailed on `dateIdeaId.isEmpty()` while **nothing ever calls `setDateIdeaId`**, the preference had an empty `coupleId`, and it wrote to `date_plan_preferences` — a collection **no screen reads**. So "Create Plan" silently saved nothing. **Fix (R15, N-001)**: `BucketListViewModel` resolves the couple **itself** in `init` via `CoupleRepository.getCoupleForUser(uid)` → `setCoupleId` → `loadItems` (mirrors `MemoryLaneViewModel`/`YourProgressViewModel`, the correct pattern). Bucket items encrypt at rest (`enc:v1:`) once a real coupleId flows. diff --git a/docs/brand/generated-art/dark/illustration_home_tonight_ritual_dark.png b/docs/brand/generated-art/dark/illustration_home_tonight_ritual_dark.png new file mode 100644 index 00000000..873d3f90 Binary files /dev/null and b/docs/brand/generated-art/dark/illustration_home_tonight_ritual_dark.png differ diff --git a/docs/brand/generated-art/dark/illustration_home_tonight_ritual_dark.svg b/docs/brand/generated-art/dark/illustration_home_tonight_ritual_dark.svg new file mode 100644 index 00000000..edd6127e --- /dev/null +++ b/docs/brand/generated-art/dark/illustration_home_tonight_ritual_dark.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/brand/generated-art/illustration_home_tonight_ritual.png b/docs/brand/generated-art/illustration_home_tonight_ritual.png new file mode 100644 index 00000000..1c03d6a6 Binary files /dev/null and b/docs/brand/generated-art/illustration_home_tonight_ritual.png differ diff --git a/docs/brand/generated-art/illustration_home_tonight_ritual.svg b/docs/brand/generated-art/illustration_home_tonight_ritual.svg new file mode 100644 index 00000000..ad4c980c --- /dev/null +++ b/docs/brand/generated-art/illustration_home_tonight_ritual.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/screenshots/Selection_1536.png b/docs/screenshots/Selection_1536.png new file mode 100644 index 00000000..5cd3f472 Binary files /dev/null and b/docs/screenshots/Selection_1536.png differ