feat(home): HomeScreen rewrite, HomePriorityEngine polish, CoupleRepository E2EE wiring, OutcomeCheckInDialog, YourProgress, MemoryLane, settings/pairing/paywall/play/wheel/question screens cleanup, brand illustrations, QA docs

This commit is contained in:
null 2026-06-29 16:51:46 -05:00
parent 912b8c8093
commit f6291e1f2e
41 changed files with 458 additions and 344 deletions

2
.gitignore vendored
View File

@ -85,3 +85,5 @@ docs/brand/exports/
# Scratch workspace (transient)
scratchpad/
SECURITY.md
Future.md

View File

@ -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 `<bitmap>` `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.**
>

File diff suppressed because one or more lines are too long

View File

@ -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

View File

@ -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,

View File

@ -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<Unit> = runCatching {
val newWrapped = encryptionManager.rewrapWithNewPhrase(coupleId, newPhrase).getOrElse { throw it }
coupleDataSource.updateWrappedKey(coupleId, newWrapped)

View File

@ -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,

View File

@ -7,5 +7,18 @@ interface CoupleRepository {
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String, recoveryPhrase: String): Result<String>
suspend fun updateStreak(coupleId: String): Result<Unit>
suspend fun leaveCouple(userId: String): Result<Unit>
/**
* 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<Unit>
}

View File

@ -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 {

View File

@ -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,

View File

@ -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?")

View File

@ -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(

View File

@ -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,

View File

@ -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) }
)
if (state.isPaired) {
StreakCard(
streakCount = state.streakCount,
partnerName = state.partnerName,
onPartner = onPartner
)
onTogether = {
onNavigate(if (state.isPaired) AppRoute.ACTIVITY else AppRoute.CREATE_INVITE)
}
)
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)
) {
Box(contentAlignment = Alignment.Center) {
HomeGlyphIcon(
resId = R.drawable.glyph_streak,
resId = icon,
contentDescription = null,
tint = CloserPalette.PinkAccentDeep,
modifier = Modifier.size(28.dp)
modifier = Modifier.size(17.dp)
)
}
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
if (streakCount > 0) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
Text(
text = streakCount.toString(),
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
text = label,
style = MaterialTheme.typography.labelMedium.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,
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,39 +532,39 @@ 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))
.size(14.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(CloserPalette.PinkBright)
)
}
}
}
}
Text(
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<PendingActionCard>,
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
)
actions.take(3).forEach { card ->
PendingActionCardView(
card = card,
onClick = {
if (card.target == HomeActionTarget.Game) onJoinGame() else onAction(card)
}
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 <Game>" 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)
)
}
}
@ -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

View File

@ -132,6 +132,7 @@ data class HomeUiState(
val categories: List<HomeCategorySummary> = 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 = "Todays 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 = "Todays 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,

View File

@ -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

View File

@ -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 {
"Youve started tracking how your relationship feels. Check back at 30, 60, and 90 days to see what changes."
"Youve 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<Outcome>) {
@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(),

View File

@ -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 ?: "Couldnt submit check-in.") }
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldnt save your check-in.") }
}
}
}

View File

@ -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
)

View File

@ -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

View File

@ -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"
)

View File

@ -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
)

View File

@ -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

View File

@ -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"
),

View File

@ -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
)

View File

@ -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<String> {
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"
),

View File

@ -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")
}
}
}

View File

@ -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
)

View File

@ -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) }
)
}

View File

@ -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",

View File

@ -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
)

View File

@ -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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -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 }
)

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="512" viewBox="0 0 1024 512" role="img" aria-label="Closer home ritual illustration dark">
<defs>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="16" stdDeviation="22" flood-color="#050108" flood-opacity="0.44"/>
</filter>
</defs>
<rect width="1024" height="512" rx="48" fill="#1B0C25"/>
<path d="M0 0 H1024 V210 C832 192 688 236 529 208 C357 178 208 86 0 116 Z" fill="#261433"/>
<path d="M0 321 C165 281 312 323 457 342 C626 364 796 312 1024 274 V512 H0 Z" fill="#3A1B44" opacity="0.74"/>
<circle cx="842" cy="122" r="118" fill="#5E3479" opacity="0.46"/>
<circle cx="164" cy="398" r="156" fill="#25102F" opacity="0.86"/>
<path d="M108 363 C235 312 353 314 490 354 C633 396 774 386 916 323 V512 H108 Z" fill="#0E0416" opacity="0.5"/>
<ellipse cx="512" cy="388" rx="338" ry="66" fill="#7B568E" opacity="0.34"/>
<ellipse cx="512" cy="388" rx="276" ry="50" fill="#A7195A" opacity="0.24"/>
<g filter="url(#shadow)">
<rect x="172" y="140" width="226" height="176" rx="32" fill="#FFF8FC"/>
<rect x="215" y="190" width="140" height="18" rx="9" fill="#D8C1EE"/>
<rect x="215" y="226" width="106" height="14" rx="7" fill="#ECDDF5"/>
<rect x="215" y="258" width="132" height="14" rx="7" fill="#F0D6E7"/>
<circle cx="210" cy="158" r="28" fill="#F2D7EA"/>
<path d="M210 139 L230 148 V167 C230 184 220 194 210 199 C200 194 190 184 190 167 V148 Z" fill="#6B3C82"/>
</g>
<g filter="url(#shadow)">
<rect x="626" y="140" width="226" height="176" rx="32" fill="#FFF8FC"/>
<rect x="672" y="190" width="132" height="18" rx="9" fill="#D8C1EE"/>
<rect x="672" y="226" width="118" height="14" rx="7" fill="#ECDDF5"/>
<rect x="672" y="258" width="142" height="14" rx="7" fill="#F0D6E7"/>
<circle cx="814" cy="158" r="28" fill="#F2D7EA"/>
<path d="M814 181 C799 167 785 157 785 141 C785 131 792 123 802 123 C808 123 812 127 814 132 C818 127 823 123 830 123 C840 123 847 131 847 141 C847 157 830 167 814 181 Z" fill="#A7195A"/>
</g>
<g filter="url(#shadow)">
<circle cx="512" cy="238" r="92" fill="#23112F"/>
<path d="M512 300 C468 260 431 231 431 190 C431 163 449 145 474 145 C492 145 505 156 512 172 C520 156 534 145 552 145 C576 145 594 163 594 190 C594 231 557 260 512 300 Z" fill="#D987F4"/>
<path d="M512 286 C477 254 446 229 446 194 C446 171 461 157 481 157 C497 157 508 168 512 181 C518 168 530 157 546 157 C566 157 580 171 580 194 C580 229 548 254 512 286 Z" fill="#FF9EBB" opacity="0.82"/>
<circle cx="512" cy="225" r="20" fill="#FFF8FC"/>
<path d="M503 276 L512 225 L521 276 Z" fill="#FFF8FC"/>
</g>
<circle cx="220" cy="116" r="18" fill="#C59AF5" opacity="0.46"/>
<circle cx="805" cy="340" r="22" fill="#F5AFCF" opacity="0.18"/>
<path d="M160 300 C196 274 232 268 269 284" fill="none" stroke="#D9B7F6" stroke-width="10" stroke-linecap="round" opacity="0.4"/>
<path d="M754 108 C787 88 821 84 854 100" fill="none" stroke="#F1A8C8" stroke-width="10" stroke-linecap="round" opacity="0.44"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="512" viewBox="0 0 1024 512" role="img" aria-label="Closer home ritual illustration">
<defs>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#4F2764" flood-opacity="0.18"/>
</filter>
</defs>
<rect width="1024" height="512" rx="48" fill="#FFF8FC"/>
<path d="M0 0 H1024 V210 C832 192 688 236 529 208 C357 178 208 86 0 116 Z" fill="#F3E4FA"/>
<path d="M0 321 C165 281 312 323 457 342 C626 364 796 312 1024 274 V512 H0 Z" fill="#F8CDE5" opacity="0.74"/>
<circle cx="842" cy="122" r="118" fill="#F8D5E8" opacity="0.72"/>
<circle cx="164" cy="398" r="156" fill="#E5D1FB" opacity="0.84"/>
<path d="M108 363 C235 312 353 314 490 354 C633 396 774 386 916 323 V512 H108 Z" fill="#FFFFFF" opacity="0.48"/>
<ellipse cx="512" cy="388" rx="338" ry="66" fill="#C59AF5" opacity="0.36"/>
<ellipse cx="512" cy="388" rx="276" ry="50" fill="#F5AFCF" opacity="0.28"/>
<g filter="url(#shadow)">
<rect x="172" y="140" width="226" height="176" rx="32" fill="#FFF8FC"/>
<rect x="215" y="190" width="140" height="18" rx="9" fill="#D8C1EE"/>
<rect x="215" y="226" width="106" height="14" rx="7" fill="#ECDDF5"/>
<rect x="215" y="258" width="132" height="14" rx="7" fill="#F0D6E7"/>
<circle cx="210" cy="158" r="28" fill="#F2D7EA"/>
<path d="M210 139 L230 148 V167 C230 184 220 194 210 199 C200 194 190 184 190 167 V148 Z" fill="#6B3C82"/>
</g>
<g filter="url(#shadow)">
<rect x="626" y="140" width="226" height="176" rx="32" fill="#FFF8FC"/>
<rect x="672" y="190" width="132" height="18" rx="9" fill="#D8C1EE"/>
<rect x="672" y="226" width="118" height="14" rx="7" fill="#ECDDF5"/>
<rect x="672" y="258" width="142" height="14" rx="7" fill="#F0D6E7"/>
<circle cx="814" cy="158" r="28" fill="#F2D7EA"/>
<path d="M814 181 C799 167 785 157 785 141 C785 131 792 123 802 123 C808 123 812 127 814 132 C818 127 823 123 830 123 C840 123 847 131 847 141 C847 157 830 167 814 181 Z" fill="#A7195A"/>
</g>
<g filter="url(#shadow)">
<circle cx="512" cy="238" r="92" fill="#23112F"/>
<path d="M512 300 C468 260 431 231 431 190 C431 163 449 145 474 145 C492 145 505 156 512 172 C520 156 534 145 552 145 C576 145 594 163 594 190 C594 231 557 260 512 300 Z" fill="#D987F4"/>
<path d="M512 286 C477 254 446 229 446 194 C446 171 461 157 481 157 C497 157 508 168 512 181 C518 168 530 157 546 157 C566 157 580 171 580 194 C580 229 548 254 512 286 Z" fill="#FF9EBB" opacity="0.82"/>
<circle cx="512" cy="225" r="20" fill="#FFF8FC"/>
<path d="M503 276 L512 225 L521 276 Z" fill="#FFF8FC"/>
</g>
<circle cx="220" cy="116" r="18" fill="#C59AF5" opacity="0.58"/>
<circle cx="805" cy="340" r="22" fill="#A7195A" opacity="0.12"/>
<path d="M160 300 C196 274 232 268 269 284" fill="none" stroke="#D9B7F6" stroke-width="10" stroke-linecap="round" opacity="0.52"/>
<path d="M754 108 C787 88 821 84 854 100" fill="none" stroke="#F1A8C8" stroke-width="10" stroke-linecap="round" opacity="0.58"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB