From 7b1443e578e57406e6ec52fa23b71632b10e0204 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 29 Jun 2026 21:44:26 -0500 Subject: [PATCH] feat(pairing): CreateInviteScreen invite-code UX, MainActivity nav wiring, LocalQuestionContent question pool expansion, Future.md planning --- ClaudeReport.md | 1 + Future.md | 13 +++-- app/src/main/java/app/closer/MainActivity.kt | 43 ++++++++++++++-- .../closer/ui/pairing/CreateInviteScreen.kt | 46 +++++++++++++++++ .../ui/questions/LocalQuestionContent.kt | 50 ++++++++++++------- 5 files changed, 125 insertions(+), 28 deletions(-) diff --git a/ClaudeReport.md b/ClaudeReport.md index 918c7bd4..c993e164 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) +- **R22 (2026-06-29) — SECURITY.md recs #6 + #7 implemented, then full QA pass. 0 new defects, 0 FATAL.** **Work (uncommitted):** **#6 recovery-phrase save-confirmation at pairing** — the invite screen now makes the inviter **re-type one random word of the phrase** ("type word #N") before pairing feels done → "✓ Saved — you're all set." (`CreateInviteScreen.kt`). ✓ verified live on a fresh account (5558): renders, correct word → confirmed, index randomizes per entry (#5 then #8). **#7 biometric app-lock re-arms on background** — `MainActivity` lifecycle observer drops the unlocked session after the app is backgrounded past a 60s grace (`BIOMETRIC_RELOCK_GRACE_MS`), so a picked-up open phone re-prompts (not only cold-start); grace avoids re-locking on quick task-switches. Code-complete + compiles; **live re-lock pending a physical device** (emulators have no enrolled biometric/PIN). SECURITY.md/Future.md updated (#6/#7 → done). **QA run:** cheap gates ALL GREEN — build + **210 unit + 24 functions**, theme-scan CRIT **0**, painter-xml **0**; baseline both **free**, **0 active sessions**, **0 FATAL** both. Smoke: **5556 6/6** (launcher + all 5 notif cold-starts open&stay); **5554 launcher PASS + 5 FCM-delivery BLOCKs** (environmental "flaky emulator FCM, rerun" — **0 FAIL**, no crashes; shared notif code path proven by 5556). **A cornerstone live** (free → Desire Sync → Paywall, warmed "Full answer history and growth" renders). **D** carries from R20 (no rules/crypto change); **B/E** + this session's copy/bubble/#6/reveal verified R21. **INFRA finding (not app):** the 2nd/3rd emulator crashed seconds after boot with `eglMakeCurrent failed` / `Draw context is NULL` — **host GPU/EGL context exhaustion** from running 3 emulators on the hardware GPU. Fixed by killing the spare (5558) + relaunching the QA/Sam pair with **`-gpu swiftshader_indirect`** (software GL) — stable since. Saved to memory (QA-ops). **Verdict: R22 — #6 shipped+verified, #7 code-complete (needs-device), build stable + cornerstones hold, 0 new defects, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked.** Uncommitted (user commits): `MainActivity.kt`, `CreateInviteScreen.kt`, `SECURITY.md`, `Future.md`, `ClaudeReport.md`, `ClaudeQACoverage.md` (+ the broader session work). **N-TODAY-001 (P3, FIXED) — Today reveal state was confusing (user-reported):** after answering + revealing, the Today tab still showed the **editable answer form + a prominent "Save privately"** (looked re-answerable) AND a card titled **"Answer revealed"** that showed only the user's *own* answer (the mutual reveal is actually behind the "View reveal" button). Fixed in `LocalQuestionContent.kt` (Today-only — sole caller `DailyQuestionScreen`): the answer form now hides once `submitted`, and the card is retitled **"Your answer"** with an accurate status line ("Saved privately — waiting for your partner" / "…ready to reveal together" / "Revealed together — open View reveal to compare"). Verified live (5554, revealed state): form gone, card reads "Your answer / Cozy / Revealed together — open View reveal to compare"; 0 FATAL. - **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.) diff --git a/Future.md b/Future.md index 62d992da..dbc68af8 100644 --- a/Future.md +++ b/Future.md @@ -64,13 +64,12 @@ Improvement & feature ideas surfaced while QA-testing as a consumer (each works 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 would block non-app clients entirely (defense-in-depth). *Prompted by:* R7 D3 raw-API angle. -- **Biometric app-lock re-locks on cold-start/process-death but maybe not plain background→resume.** R15 code review: - `MainActivity` gates `AppNavigation` behind `BiometricLockScreen` when `biometricLoginEnabled` and `sessionVerified` - is false; `sessionVerified` is a `remember{}` that resets on Activity recreation (cold-start, process death) — so the - lock re-arms there — but a plain background→foreground without recreation keeps `sessionVerified = true`, so it may not - re-prompt. Architecturally sound (no compose-tree bypass; content isn't composed until unlocked), but consider - re-locking on `ON_STOP`/timeout so a picked-up unlocked phone re-prompts. *Prompted by:* R15 Pass M code audit (not - live-tested — emulator has no enrolled biometric). +- **✅ DONE (R21) — Biometric app-lock now re-arms on background/timeout (was: only cold-start/process-death).** + `MainActivity` observes the lifecycle: while the lock is on and the session is unlocked, it records when the app is + backgrounded and **re-locks if it returns after >60s away** (`BIOMETRIC_RELOCK_GRACE_MS`) — so a picked-up, already-open + phone re-prompts, not only on Activity recreation. The grace window avoids re-locking on quick task-switches (the + biometric prompt, photo picker, share sheet). Code-complete + compiles; **live re-lock not yet driven** — emulators have + no enrolled biometric/PIN, so verify on a physical device. (SECURITY.md rec #7.) > Artwork to generate (ChatGPT prompts, house-style-matched) lives in `ClaudeBrandingReview.md`, not here. diff --git a/app/src/main/java/app/closer/MainActivity.kt b/app/src/main/java/app/closer/MainActivity.kt index 15b2dfc3..e841e7cf 100644 --- a/app/src/main/java/app/closer/MainActivity.kt +++ b/app/src/main/java/app/closer/MainActivity.kt @@ -2,6 +2,7 @@ package app.closer import android.content.Intent import android.os.Bundle +import android.os.SystemClock import android.util.Log import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity @@ -14,6 +15,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState @@ -26,6 +28,9 @@ import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope import android.Manifest import android.content.pm.PackageManager @@ -52,6 +57,13 @@ import dagger.hilt.android.AndroidEntryPoint import java.io.File import javax.inject.Inject +/** + * How long the app may sit in the background before the biometric app-lock re-arms. A short grace + * avoids re-locking on quick task-switches (the biometric prompt, a photo picker, the share sheet) + * while still re-prompting a picked-up phone that's been away for a while. SECURITY.md rec #7. + */ +private const val BIOMETRIC_RELOCK_GRACE_MS = 60_000L + @AndroidEntryPoint class MainActivity : AppCompatActivity() { @Inject lateinit var settingsRepository: SettingsRepository @@ -121,9 +133,34 @@ class MainActivity : AppCompatActivity() { } var sessionVerified by remember { mutableStateOf(false) } - val needsBiometricLock = settings.biometricLoginEnabled - && authRepository.currentUserId != null - && !sessionVerified + val lockEnabled = settings.biometricLoginEnabled && authRepository.currentUserId != null + + // Auto re-lock: while unlocked, drop the verified session once the app has been in the + // background past the grace window, so a picked-up, already-open phone re-prompts — not + // only on cold-start. (Previously `sessionVerified` survived background→resume, so an + // unlocked phone stayed open indefinitely. SECURITY.md rec #7.) + if (lockEnabled && sessionVerified) { + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + var backgroundedAt = 0L + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> backgroundedAt = SystemClock.elapsedRealtime() + Lifecycle.Event.ON_START -> + if (backgroundedAt != 0L && + SystemClock.elapsedRealtime() - backgroundedAt >= BIOMETRIC_RELOCK_GRACE_MS + ) { + sessionVerified = false + } + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + } + + val needsBiometricLock = lockEnabled && !sessionVerified CloserTheme(darkTheme = useDarkTheme) { SideEffect { 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 087e4b6d..d480e0b5 100644 --- a/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt @@ -39,9 +39,13 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -290,6 +294,48 @@ fun CreateInviteScreen( Spacer(Modifier.width(6.dp)) Text("Copy phrase", style = MaterialTheme.typography.labelMedium) } + + // Save-confirmation: prove you actually wrote it down by re-typing one + // word. The phrase is the only way to recover your history, so don't let + // pairing feel "done" until it's been saved. SECURITY.md rec #6. + val phraseWords = remember(phrase) { + phrase.trim().split(Regex("\\s+")).filter { it.isNotBlank() } + } + val challengeIndex = remember(phrase) { + if (phraseWords.isEmpty()) 0 else phraseWords.indices.random() + } + var confirmWord by remember(phrase) { mutableStateOf("") } + val confirmed = phraseWords.getOrNull(challengeIndex) + ?.equals(confirmWord.trim(), ignoreCase = true) == true + + Spacer(Modifier.height(4.dp)) + if (confirmed) { + Text( + "✓ Saved — you're all set.", + style = MaterialTheme.typography.labelMedium, + color = SettingsPrimaryDeep, + fontWeight = FontWeight.SemiBold + ) + } else { + Text( + "Confirm you saved it — type word #${challengeIndex + 1}.", + style = MaterialTheme.typography.labelMedium, + color = SettingsMuted + ) + OutlinedTextField( + value = confirmWord, + onValueChange = { confirmWord = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + placeholder = { Text("word #${challengeIndex + 1}") }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = SettingsPrimaryDeep, + unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f), + cursorColor = SettingsPrimaryDeep + ) + ) + } } } } 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 49b7f9d2..4f1db0f2 100644 --- a/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt +++ b/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt @@ -124,18 +124,24 @@ fun LocalQuestionContent( onToggleHelp = {}, showHelp = false ) - QuestionAnswerInput( - question = question, - pendingWrittenText = state.pendingWrittenText, - pendingSelectedOptionIds = state.pendingSelectedOptionIds, - pendingScaleValue = state.pendingScaleValue, - onWrittenTextChanged = onWrittenTextChanged, - onOptionToggled = onOptionToggled, - onScaleChanged = onScaleChanged, - onSubmit = onSubmit, - canSubmit = canSubmit, - isSubmitting = false - ) + // Show the editable answer form only until it's saved. Once submitted, the + // SubmittedAnswerCard below replaces it — leaving the form (with its "Save + // privately" button) on screen after answering made it look like you still + // needed to answer / could re-answer. + if (!state.submitted) { + QuestionAnswerInput( + question = question, + pendingWrittenText = state.pendingWrittenText, + pendingSelectedOptionIds = state.pendingSelectedOptionIds, + pendingScaleValue = state.pendingScaleValue, + onWrittenTextChanged = onWrittenTextChanged, + onOptionToggled = onOptionToggled, + onScaleChanged = onScaleChanged, + onSubmit = onSubmit, + canSubmit = canSubmit, + isSubmitting = false + ) + } AnimatedVisibility( visible = state.submitted, @@ -250,10 +256,13 @@ private fun SubmittedAnswerCard( question: Question, state: LocalQuestionUiState ) { - val label = when { - state.isRevealed -> "Answer revealed" - !state.partnerHasAnswered -> "Private answer saved — waiting for partner" - else -> "Private answer saved" + // This card shows the user's OWN answer, so title it as such. The status line carries the + // shared state — the old "Answer revealed" title read as if both answers were shown here, when + // the mutual reveal actually lives behind the "View reveal" button. + val status = when { + state.isRevealed -> "Revealed together — open View reveal to compare" + !state.partnerHasAnswered -> "Saved privately — waiting for your partner" + else -> "Saved privately — ready to reveal together" } Surface( modifier = Modifier.fillMaxWidth(), @@ -282,7 +291,7 @@ private fun SubmittedAnswerCard( } Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { Text( - text = label, + text = "Your answer", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold @@ -290,10 +299,15 @@ private fun SubmittedAnswerCard( Text( text = answerSummary(question, state), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis ) + Text( + text = status, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } }