refactor(ui): celebration overlay polish, activity screen layout, home screen streak dialog, pairing success cleanup

This commit is contained in:
null 2026-06-23 19:26:41 -05:00
parent 17d7489dd8
commit 272c8997d0
12 changed files with 389 additions and 155 deletions

View File

@ -473,6 +473,9 @@ fun AppNavigation(
composable(route = AppRoute.ACTIVITY) {
ActivityScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.ART_PREVIEW) {
app.closer.ui.debug.ArtPreviewScreen(onNavigate = navigateRoute)
}
}
}
}

View File

@ -55,6 +55,7 @@ object AppRoute {
const val RECOVERY = "recovery"
const val YOUR_PROGRESS = "your_progress"
const val ACTIVITY = "activity"
const val ART_PREVIEW = "art_preview"
const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
fun pairingSuccess(coupleId: String) = "pairing_success/$coupleId"

View File

@ -45,6 +45,17 @@ class FirestoreActivityDataSource @Inject constructor(
awaitClose { reg.remove() }
}
/** Live count of unread activity items (0 on error, so the badge fails closed). */
fun observeUnreadCount(userId: String): Flow<Int> = callbackFlow {
val reg = queueRef(userId)
.whereEqualTo("read", false)
.addSnapshotListener { snap, err ->
if (err != null) { trySend(0); return@addSnapshotListener }
trySend(snap?.size() ?: 0)
}
awaitClose { reg.remove() }
}
/** Marks every unread item read. Best-effort. */
suspend fun markAllRead(userId: String) {
val unread = queueRef(userId).whereEqualTo("read", false).get().await()

View File

@ -6,12 +6,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -28,11 +27,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.model.ActivityItem
import app.closer.data.remote.FirestoreActivityDataSource
import androidx.compose.ui.res.painterResource
import app.closer.R
import app.closer.domain.repository.AuthRepository
import app.closer.ui.components.CloserCard
import app.closer.ui.components.CloserHeartLoader
import app.closer.ui.components.IllustrationPlaceholder
import app.closer.ui.settings.SettingsSubpage
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerCardColor
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -105,16 +107,13 @@ fun ActivityScreen(
@Composable
private fun ActivityRow(item: ActivityItem) {
Card(
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(
containerColor = if (item.read) MaterialTheme.colorScheme.surface else CloserPalette.PurpleSoft
)
containerColor = if (item.read) closerCardColor() else CloserPalette.PurpleSoft
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = item.title,
text = item.title.ifBlank { "Shared moment" },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
@ -144,15 +143,13 @@ private fun ActivityEmptyState(modifier: Modifier = Modifier) {
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// TODO(asset): replace with Image(painterResource(R.drawable.activity_empty_state))
// Generated empty-state illustration — 1024×1024, pastel palette.
IllustrationPlaceholder(
assetName = "activity_empty_state",
sizeHint = "1024×1024",
modifier = Modifier.size(160.dp)
Image(
painter = painterResource(R.drawable.illustration_together_empty),
contentDescription = null,
modifier = Modifier.size(180.dp)
)
Text(
text = "Your shared moments will show up here",
text = "Your story, together",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
@ -160,7 +157,7 @@ private fun ActivityEmptyState(modifier: Modifier = Modifier) {
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "Answers, games, and milestones you reach together will collect here.",
text = "Every answer you reveal, every game you play, every little milestone — they'll gather here, just for the two of you.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,

View File

@ -17,7 +17,11 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.ui.res.painterResource
import app.closer.R
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
@ -429,7 +433,15 @@ private fun BothAnsweredSealedState(
onHistory: () -> Unit
) {
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(R.drawable.illustration_reveal_celebration),
contentDescription = null,
modifier = Modifier.size(140.dp)
)
RevealPill("Both answers are in")
Text(
text = question?.text ?: "Both of you answered.",

View File

@ -14,21 +14,23 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import app.closer.ui.theme.CloserPalette
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import app.closer.R
import kotlin.math.sin
import kotlin.random.Random
/**
* A calm, on-brand celebration: a gentle upward drift of pastel hearts and petals with a
* soft haptic. Deliberately restrained (the 2026 "calm delight" direction) no screen-blocking
* confetti cannon. Draws everything with Compose vectors, so it needs no image assets.
* A calm, on-brand celebration: a gentle upward drift of the designed pastel heart/petal
* sprites with a soft haptic. Deliberately restrained (the 2026 "calm delight" direction)
* no screen-blocking confetti cannon.
*
* Drop it as the top layer of a Box. It plays once each time [visible] flips to true and calls
* [onFinished] when the drift completes. Honors the system "remove animations" setting.
@ -57,7 +59,6 @@ fun CelebrationOverlay(
LaunchedEffect(visible) {
if (!visible) return@LaunchedEffect
if (reducedMotion) {
// Respect reduced-motion: skip the animation, still fire the completion callback.
onFinished()
return@LaunchedEffect
}
@ -71,9 +72,12 @@ fun CelebrationOverlay(
if (!visible || reducedMotion || particles.isEmpty()) return
val heart = ImageBitmap.imageResource(R.drawable.particle_heart)
val petal = ImageBitmap.imageResource(R.drawable.particle_petal)
Canvas(modifier = modifier.fillMaxSize()) {
val t = progress.value
particles.forEach { p -> drawParticle(p, t) }
particles.forEach { p -> drawParticle(p, t, heart, petal) }
}
}
@ -84,16 +88,7 @@ private data class Particle(
val sizeDp: Float,
val delay: Float,
val rotation: Float,
val isHeart: Boolean,
val color: Color
)
private val PARTICLE_COLORS = listOf(
CloserPalette.PinkWheel,
CloserPalette.PinkShell,
CloserPalette.PinkBright,
CloserPalette.PurpleRich,
CloserPalette.PurpleGlow
val isHeart: Boolean
)
private fun generateParticles(count: Int): List<Particle> {
@ -103,16 +98,20 @@ private fun generateParticles(count: Int): List<Particle> {
startXFraction = rng.nextFloat(),
swayAmplitude = 12f + rng.nextFloat() * 28f,
swayFrequency = 1.5f + rng.nextFloat() * 2.5f,
sizeDp = 14f + rng.nextFloat() * 16f,
sizeDp = 16f + rng.nextFloat() * 18f,
delay = rng.nextFloat() * 0.35f,
rotation = rng.nextFloat() * 40f - 20f,
isHeart = rng.nextFloat() < 0.6f,
color = PARTICLE_COLORS[rng.nextInt(PARTICLE_COLORS.size)]
isHeart = rng.nextFloat() < 0.6f
)
}
}
private fun DrawScope.drawParticle(p: Particle, globalProgress: Float) {
private fun DrawScope.drawParticle(
p: Particle,
globalProgress: Float,
heart: ImageBitmap,
petal: ImageBitmap
) {
// Each particle runs from its [delay] to the end of the timeline.
val local = ((globalProgress - p.delay) / (1f - p.delay)).coerceIn(0f, 1f)
if (local <= 0f) return
@ -127,46 +126,19 @@ private fun DrawScope.drawParticle(p: Particle, globalProgress: Float) {
local < 0.12f -> local / 0.12f
local > 0.65f -> ((1f - local) / 0.35f).coerceIn(0f, 1f)
else -> 1f
}
val sizePx = p.sizeDp * density
}.coerceIn(0f, 1f)
val sizePx = (p.sizeDp * density).toInt().coerceAtLeast(1)
val img = if (p.isHeart) heart else petal
rotate(degrees = p.rotation + local * 30f, pivot = Offset(x, y)) {
if (p.isHeart) {
drawPath(heartPath(x, y, sizePx), color = p.color.copy(alpha = alpha * 0.9f))
} else {
drawPath(petalPath(x, y, sizePx), color = p.color.copy(alpha = alpha * 0.85f))
}
}
}
/** A soft heart centered at (cx, cy) fitting roughly within [s] px. */
private fun heartPath(cx: Float, cy: Float, s: Float): Path {
val w = s
val h = s
return Path().apply {
moveTo(cx, cy + h * 0.30f)
cubicTo(
cx - w * 0.50f, cy - h * 0.10f,
cx - w * 0.25f, cy - h * 0.45f,
cx, cy - h * 0.15f
drawImage(
image = img,
srcOffset = IntOffset.Zero,
srcSize = IntSize(img.width, img.height),
dstOffset = IntOffset((x - sizePx / 2f).toInt(), (y - sizePx / 2f).toInt()),
dstSize = IntSize(sizePx, sizePx),
alpha = alpha
)
cubicTo(
cx + w * 0.25f, cy - h * 0.45f,
cx + w * 0.50f, cy - h * 0.10f,
cx, cy + h * 0.30f
)
close()
}
}
/** A simple teardrop petal centered at (cx, cy). */
private fun petalPath(cx: Float, cy: Float, s: Float): Path {
val w = s * 0.6f
val h = s
return Path().apply {
moveTo(cx, cy - h * 0.5f)
cubicTo(cx + w, cy - h * 0.1f, cx + w * 0.4f, cy + h * 0.5f, cx, cy + h * 0.5f)
cubicTo(cx - w * 0.4f, cy + h * 0.5f, cx - w, cy - h * 0.1f, cx, cy - h * 0.5f)
close()
}
}

View File

@ -0,0 +1,123 @@
package app.closer.ui.debug
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.closer.R
import app.closer.ui.components.CelebrationOverlay
import app.closer.ui.components.CloserActionButton
import app.closer.ui.components.CloserButtonStyle
import app.closer.ui.components.CloserCard
import app.closer.ui.settings.SettingsMuted
import app.closer.ui.settings.SettingsSubpage
/**
* Debug-only gallery for the celebration art so it can be verified without pairing two devices
* (the real surfaces are gated behind paired reveal / streak / game-finish states). Reachable
* from Settings in debug builds only.
*/
@Composable
fun ArtPreviewScreen(onNavigate: (String) -> Unit = {}) {
var celebrate by remember { mutableStateOf(false) }
var showStreak by remember { mutableStateOf(false) }
if (showStreak) {
app.closer.ui.home.StreakMilestoneDialog(
milestone = 7,
partnerName = "Sofia",
onDismiss = { showStreak = false }
)
}
SettingsSubpage(title = "Art preview", onBack = { onNavigate("back") }) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = "A peek at the little moments you'll share — the art that shows up when you reveal, play, and reach milestones together.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)
)
ArtCard("When you both reveal") {
Image(painterResource(R.drawable.illustration_reveal_celebration), null, Modifier.size(200.dp))
}
ArtCard("When you hit a streak") {
Image(painterResource(R.drawable.illustration_streak_milestone), null, Modifier.size(160.dp))
}
ArtCard("Your shared story, before it begins") {
Image(painterResource(R.drawable.illustration_together_empty), null, Modifier.size(200.dp))
}
ArtCard("Little hearts in the air") {
androidx.compose.foundation.layout.Row(
horizontalArrangement = Arrangement.spacedBy(18.dp)
) {
Image(painterResource(R.drawable.particle_heart), null, Modifier.size(56.dp))
Image(painterResource(R.drawable.particle_petal), null, Modifier.size(56.dp))
}
}
CloserActionButton(
label = "Play the celebration",
onClick = { celebrate = true },
modifier = Modifier.fillMaxWidth().padding(top = 6.dp)
)
CloserActionButton(
label = "Show a streak milestone",
onClick = { showStreak = true },
style = CloserButtonStyle.Secondary,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
)
}
CelebrationOverlay(visible = celebrate, intensity = 1.6f, onFinished = { celebrate = false })
}
}
}
@Composable
private fun ArtCard(label: String, content: @Composable () -> Unit) {
CloserCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
content()
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
}
}

View File

@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.Button
import androidx.compose.ui.window.Dialog
import app.closer.ui.components.CelebrationOverlay
import app.closer.ui.components.IllustrationPlaceholder
import app.closer.ui.components.CategoryGlyph
import app.closer.ui.components.CloserActionButton
import app.closer.ui.components.CloserButtonStyle
@ -19,6 +18,9 @@ import app.closer.ui.components.CloserRadii
import app.closer.ui.components.ErrorState
import app.closer.ui.components.LoadingState
import app.closer.ui.questions.displayCategoryName
import app.closer.ui.settings.SettingsInk
import app.closer.ui.settings.SettingsMuted
import app.closer.ui.settings.SettingsSoft
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.closerCardColor
@ -295,7 +297,9 @@ private fun HomeContent(
HomeHeader(
partnerName = state.partnerName,
streakCount = state.streakCount,
isPaired = state.isPaired
isPaired = state.isPaired,
unreadActivityCount = state.unreadActivityCount,
onTogether = { onNavigate(AppRoute.ACTIVITY) }
)
if (state.isPaired) {
@ -443,22 +447,53 @@ private fun StreakCard(
private fun HomeHeader(
partnerName: String?,
streakCount: Int,
isPaired: Boolean
isPaired: Boolean,
unreadActivityCount: Int = 0,
onTogether: () -> Unit = {}
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "For tonight",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (streakCount > 0) {
HomePill("$streakCount nights showing up")
HomePill("$streakCount nights")
}
// "Together" activity 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
) {
Icon(
Icons.Filled.Favorite,
contentDescription = "Together activity",
tint = CloserPalette.PurpleRich,
modifier = Modifier.size(20.dp)
)
}
if (unreadActivityCount > 0) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(11.dp)
.clip(RoundedCornerShape(999.dp))
.background(CloserPalette.PinkBright)
)
}
}
}
Text(
@ -1376,47 +1411,52 @@ fun HomeScreenPreview() {
}
@Composable
private fun StreakMilestoneDialog(
internal fun StreakMilestoneDialog(
milestone: Int,
partnerName: String?,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Box(contentAlignment = Alignment.Center) {
Column(
Surface(
shape = RoundedCornerShape(28.dp),
color = SettingsSoft,
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(28.dp),
.fillMaxWidth()
.padding(horizontal = 24.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 28.dp, vertical = 30.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// TODO(asset): replace with Image(painterResource(R.drawable.streak_milestone_badge))
// Generated badge — 1024×1024 transparent PNG, pastel palette.
IllustrationPlaceholder(
assetName = "streak_milestone_badge",
sizeHint = "1024×1024",
modifier = Modifier.size(140.dp)
Image(
painter = painterResource(R.drawable.illustration_streak_milestone),
contentDescription = null,
modifier = Modifier.size(156.dp)
)
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(18.dp))
Text(
text = "$milestone-day streak!",
text = "$milestone nights together",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
color = SettingsInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
text = partnerName?.takeIf { it.isNotBlank() }
?.let { "You and $it have shown up $milestone days in a row." }
?: "You've shown up $milestone days in a row.",
?.let { "You and $it keep showing up for each other — $milestone nights running. The small moments are adding up to something." }
?: "You keep showing up — $milestone nights running. The small moments are adding up to something.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(20.dp))
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) {
Text("Keep it going")
Spacer(Modifier.height(24.dp))
CloserActionButton(
label = "Keep going together",
onClick = onDismiss,
modifier = Modifier.fillMaxWidth()
)
}
}
CelebrationOverlay(visible = true, intensity = 1.4f)

View File

@ -140,7 +140,8 @@ data class HomeUiState(
val showOutcomeFollowUpDialog: Boolean = false,
val outcomeFollowUpDay: OutcomeDay? = null,
/** Non-null when a streak tier was just reached and should be celebrated. */
val streakMilestone: Int? = null
val streakMilestone: Int? = null,
val unreadActivityCount: Int = 0
)
@HiltViewModel
@ -159,7 +160,8 @@ class HomeViewModel @Inject constructor(
private val datePlanRepository: DatePlanRepository,
private val sealedRevealManager: SealedRevealManager,
private val outcomeRepository: OutcomeRepository,
private val settingsRepository: SettingsRepository
private val settingsRepository: SettingsRepository,
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
@ -172,6 +174,16 @@ class HomeViewModel @Inject constructor(
loadHome()
observeAnswers()
observeCoupleState()
observeUnreadActivity()
}
private fun observeUnreadActivity() {
val uid = authRepository.currentUserId ?: return
viewModelScope.launch {
activityDataSource.observeUnreadCount(uid).collect { count ->
_uiState.update { it.copy(unreadActivityCount = count) }
}
}
}
override fun onCleared() {

View File

@ -3,6 +3,9 @@ package app.closer.ui.pairing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
@ -35,6 +38,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import app.closer.ui.components.CelebrationOverlay
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -137,10 +145,19 @@ fun PairingSuccessScreen(
label = "heartScale"
)
// Hero springs in on arrival — the "we're really connected" beat.
var appeared by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { appeared = true }
val heroScale by animateFloatAsState(
targetValue = if (appeared) 1f else 0.6f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
label = "heroScale"
)
Box(modifier = Modifier.fillMaxSize().background(SettingsBackgroundBrush)) {
Column(
modifier = Modifier
.fillMaxSize()
.background(SettingsBackgroundBrush)
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 32.dp),
@ -149,74 +166,77 @@ fun PairingSuccessScreen(
) {
Spacer(Modifier.weight(1f))
// Overlapping avatars + pulsing heart
// Hero: both partners' photos, joined by a heart, springing in for impact.
Box(
modifier = Modifier
.width(144.dp)
.height(80.dp),
.scale(heroScale)
.width(248.dp)
.height(152.dp),
contentAlignment = Alignment.Center
) {
PairAvatar(
url = state.myPhotoUrl,
modifier = Modifier
.size(80.dp)
.size(142.dp)
.align(Alignment.CenterStart)
.zIndex(1f)
)
PairAvatar(
url = state.partnerPhotoUrl,
modifier = Modifier
.size(80.dp)
.size(142.dp)
.align(Alignment.CenterEnd)
.zIndex(1f)
)
Box(
modifier = Modifier
.size(34.dp)
.size(60.dp)
.zIndex(2f)
.align(Alignment.Center)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.background)
.padding(3.dp)
.padding(5.dp)
.clip(CircleShape)
.background(SettingsSoft),
.background(SettingsPrimary),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.Favorite,
contentDescription = null,
tint = SettingsPrimary,
tint = SettingsOnPrimary,
modifier = Modifier
.size(16.dp)
.size(28.dp)
.scale(pulse)
)
}
}
Spacer(Modifier.height(28.dp))
Spacer(Modifier.height(32.dp))
Text(
text = if (state.myName.isNotBlank() && state.partnerName.isNotBlank())
"${state.myName} & ${state.partnerName}"
else "You're connected",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
text = "You're connected",
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
color = SettingsInk,
textAlign = TextAlign.Center
)
if (state.myName.isNotBlank() && state.partnerName.isNotBlank()) {
Spacer(Modifier.height(10.dp))
Text(
"You're connected.",
style = MaterialTheme.typography.bodyLarge,
color = SettingsMuted,
text = "${state.myName} & ${state.partnerName}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = SettingsPrimary,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(4.dp))
}
Spacer(Modifier.height(14.dp))
Text(
"Ready to start your story together.",
style = MaterialTheme.typography.bodyMedium,
text = "Two phones, one private space. Everything from here is just for the two of you.",
style = MaterialTheme.typography.bodyLarge,
color = SettingsMuted,
textAlign = TextAlign.Center
)
@ -239,13 +259,17 @@ fun PairingSuccessScreen(
Spacer(Modifier.height(24.dp))
}
// Celebrate the moment you connect — the same hearts as a reveal/streak.
var celebrate by remember { mutableStateOf(true) }
CelebrationOverlay(visible = celebrate, intensity = 2.4f, onFinished = { celebrate = false })
}
}
// ── Shared avatar composable ──────────────────────────────────────────────────
@Composable
private fun PairAvatar(url: String, modifier: Modifier = Modifier) {
val borderColor = SettingsSoft
val cleanUrl = url.takeIf { it.isNotBlank() }
if (cleanUrl != null) {
AsyncImage(
@ -256,22 +280,23 @@ private fun PairAvatar(url: String, modifier: Modifier = Modifier) {
error = rememberVectorPainter(Icons.Filled.Person),
modifier = modifier
.clip(CircleShape)
.border(2.5.dp, borderColor, CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(4.dp, Color.White, CircleShape)
.background(SettingsSoft)
)
} else {
// No photo yet — warm pastel placeholder rather than a flat grey chip.
Box(
modifier = modifier
.clip(CircleShape)
.border(2.5.dp, borderColor, CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
.border(4.dp, Color.White, CircleShape)
.background(SettingsSoft),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.Person,
contentDescription = null,
tint = SettingsMuted,
modifier = Modifier.size(36.dp)
tint = SettingsPrimary,
modifier = Modifier.fillMaxSize(0.5f)
)
}
}

View File

@ -431,9 +431,19 @@ fun SettingsScreen(
icon = Icons.Filled.Favorite,
label = "Together",
subtitle = "Your shared activity, answers, and milestones",
onClick = { onNavigate(AppRoute.ACTIVITY) }
onClick = { onNavigate(AppRoute.ACTIVITY) },
badgeCount = state.unreadActivityCount
)
SettingsSectionDivider()
if (app.closer.BuildConfig.DEBUG) {
SettingsRow(
icon = Icons.Filled.Favorite,
label = "Art preview (debug)",
subtitle = "Preview celebration art without pairing",
onClick = { onNavigate(AppRoute.ART_PREVIEW) }
)
SettingsSectionDivider()
}
SettingsRow(
icon = Icons.Filled.Done,
label = "Answer History",
@ -558,7 +568,8 @@ private fun SettingsRow(
label: String,
subtitle: String? = null,
onClick: () -> Unit,
tint: androidx.compose.ui.graphics.Color = SettingsMuted
tint: androidx.compose.ui.graphics.Color = SettingsMuted,
badgeCount: Int = 0
) {
Row(
modifier = Modifier
@ -603,6 +614,21 @@ private fun SettingsRow(
)
}
}
if (badgeCount > 0) {
androidx.compose.foundation.layout.Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(Color(0xFF8F67C5))
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = if (badgeCount > 9) "9+" else badgeCount.toString(),
style = MaterialTheme.typography.labelSmall,
color = Color.White
)
}
}
Icon(
Icons.AutoMirrored.Filled.ArrowForwardIos,
contentDescription = null,

View File

@ -44,7 +44,8 @@ data class SettingsUiState(
val outcomeBaselineDialogDue: Boolean = false,
val outcomeFollowUpDay: OutcomeDay? = null,
val outcomeSubmitSuccess: Boolean = false,
val outcomeError: String? = null
val outcomeError: String? = null,
val unreadActivityCount: Int = 0
)
@HiltViewModel
@ -55,7 +56,8 @@ class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val outcomeRepository: OutcomeRepository,
private val recoveryPhraseStore: RecoveryPhraseStore,
private val pendingInviteStore: PendingInviteStore
private val pendingInviteStore: PendingInviteStore,
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
@ -66,6 +68,16 @@ class SettingsViewModel @Inject constructor(
init {
loadSettings()
observeThemeMode()
observeUnreadActivity()
}
private fun observeUnreadActivity() {
val userId = authRepository.currentUserId ?: return
viewModelScope.launch {
activityDataSource.observeUnreadCount(userId).collect { count ->
_uiState.update { it.copy(unreadActivityCount = count) }
}
}
}
private fun observeThemeMode() {