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) { composable(route = AppRoute.ACTIVITY) {
ActivityScreen(onNavigate = navigateRoute) 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 RECOVERY = "recovery"
const val YOUR_PROGRESS = "your_progress" const val YOUR_PROGRESS = "your_progress"
const val ACTIVITY = "activity" const val ACTIVITY = "activity"
const val ART_PREVIEW = "art_preview"
const val PAIRING_SUCCESS = "pairing_success/{coupleId}" const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
fun pairingSuccess(coupleId: String) = "pairing_success/$coupleId" fun pairingSuccess(coupleId: String) = "pairing_success/$coupleId"

View File

@ -45,6 +45,17 @@ class FirestoreActivityDataSource @Inject constructor(
awaitClose { reg.remove() } 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. */ /** Marks every unread item read. Best-effort. */
suspend fun markAllRead(userId: String) { suspend fun markAllRead(userId: String) {
val unread = queueRef(userId).whereEqualTo("read", false).get().await() 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -28,11 +27,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.domain.model.ActivityItem import app.closer.domain.model.ActivityItem
import app.closer.data.remote.FirestoreActivityDataSource 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.domain.repository.AuthRepository
import app.closer.ui.components.CloserCard
import app.closer.ui.components.CloserHeartLoader import app.closer.ui.components.CloserHeartLoader
import app.closer.ui.components.IllustrationPlaceholder
import app.closer.ui.settings.SettingsSubpage import app.closer.ui.settings.SettingsSubpage
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerCardColor
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -105,16 +107,13 @@ fun ActivityScreen(
@Composable @Composable
private fun ActivityRow(item: ActivityItem) { private fun ActivityRow(item: ActivityItem) {
Card( CloserCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp), containerColor = if (item.read) closerCardColor() else CloserPalette.PurpleSoft
colors = CardDefaults.cardColors(
containerColor = if (item.read) MaterialTheme.colorScheme.surface else CloserPalette.PurpleSoft
)
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text( Text(
text = item.title, text = item.title.ifBlank { "Shared moment" },
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
@ -144,15 +143,13 @@ private fun ActivityEmptyState(modifier: Modifier = Modifier) {
modifier = Modifier.padding(32.dp), modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// TODO(asset): replace with Image(painterResource(R.drawable.activity_empty_state)) Image(
// Generated empty-state illustration — 1024×1024, pastel palette. painter = painterResource(R.drawable.illustration_together_empty),
IllustrationPlaceholder( contentDescription = null,
assetName = "activity_empty_state", modifier = Modifier.size(180.dp)
sizeHint = "1024×1024",
modifier = Modifier.size(160.dp)
) )
Text( Text(
text = "Your shared moments will show up here", text = "Your story, together",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
@ -160,7 +157,7 @@ private fun ActivityEmptyState(modifier: Modifier = Modifier) {
modifier = Modifier.padding(top = 16.dp) modifier = Modifier.padding(top = 16.dp)
) )
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxWidth 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.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -429,7 +433,15 @@ private fun BothAnsweredSealedState(
onHistory: () -> Unit onHistory: () -> Unit
) { ) {
RevealMessageCard { 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") RevealPill("Both answers are in")
Text( Text(
text = question?.text ?: "Both of you answered.", 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.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback 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.math.sin
import kotlin.random.Random import kotlin.random.Random
/** /**
* A calm, on-brand celebration: a gentle upward drift of pastel hearts and petals with a * A calm, on-brand celebration: a gentle upward drift of the designed pastel heart/petal
* soft haptic. Deliberately restrained (the 2026 "calm delight" direction) no screen-blocking * sprites with a soft haptic. Deliberately restrained (the 2026 "calm delight" direction)
* confetti cannon. Draws everything with Compose vectors, so it needs no image assets. * 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 * 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. * [onFinished] when the drift completes. Honors the system "remove animations" setting.
@ -57,7 +59,6 @@ fun CelebrationOverlay(
LaunchedEffect(visible) { LaunchedEffect(visible) {
if (!visible) return@LaunchedEffect if (!visible) return@LaunchedEffect
if (reducedMotion) { if (reducedMotion) {
// Respect reduced-motion: skip the animation, still fire the completion callback.
onFinished() onFinished()
return@LaunchedEffect return@LaunchedEffect
} }
@ -71,9 +72,12 @@ fun CelebrationOverlay(
if (!visible || reducedMotion || particles.isEmpty()) return 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()) { Canvas(modifier = modifier.fillMaxSize()) {
val t = progress.value 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 sizeDp: Float,
val delay: Float, val delay: Float,
val rotation: Float, val rotation: Float,
val isHeart: Boolean, val isHeart: Boolean
val color: Color
)
private val PARTICLE_COLORS = listOf(
CloserPalette.PinkWheel,
CloserPalette.PinkShell,
CloserPalette.PinkBright,
CloserPalette.PurpleRich,
CloserPalette.PurpleGlow
) )
private fun generateParticles(count: Int): List<Particle> { private fun generateParticles(count: Int): List<Particle> {
@ -103,16 +98,20 @@ private fun generateParticles(count: Int): List<Particle> {
startXFraction = rng.nextFloat(), startXFraction = rng.nextFloat(),
swayAmplitude = 12f + rng.nextFloat() * 28f, swayAmplitude = 12f + rng.nextFloat() * 28f,
swayFrequency = 1.5f + rng.nextFloat() * 2.5f, swayFrequency = 1.5f + rng.nextFloat() * 2.5f,
sizeDp = 14f + rng.nextFloat() * 16f, sizeDp = 16f + rng.nextFloat() * 18f,
delay = rng.nextFloat() * 0.35f, delay = rng.nextFloat() * 0.35f,
rotation = rng.nextFloat() * 40f - 20f, rotation = rng.nextFloat() * 40f - 20f,
isHeart = rng.nextFloat() < 0.6f, isHeart = rng.nextFloat() < 0.6f
color = PARTICLE_COLORS[rng.nextInt(PARTICLE_COLORS.size)]
) )
} }
} }
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. // Each particle runs from its [delay] to the end of the timeline.
val local = ((globalProgress - p.delay) / (1f - p.delay)).coerceIn(0f, 1f) val local = ((globalProgress - p.delay) / (1f - p.delay)).coerceIn(0f, 1f)
if (local <= 0f) return if (local <= 0f) return
@ -127,46 +126,19 @@ private fun DrawScope.drawParticle(p: Particle, globalProgress: Float) {
local < 0.12f -> local / 0.12f local < 0.12f -> local / 0.12f
local > 0.65f -> ((1f - local) / 0.35f).coerceIn(0f, 1f) local > 0.65f -> ((1f - local) / 0.35f).coerceIn(0f, 1f)
else -> 1f else -> 1f
} }.coerceIn(0f, 1f)
val sizePx = p.sizeDp * density
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)) { rotate(degrees = p.rotation + local * 30f, pivot = Offset(x, y)) {
if (p.isHeart) { drawImage(
drawPath(heartPath(x, y, sizePx), color = p.color.copy(alpha = alpha * 0.9f)) image = img,
} else { srcOffset = IntOffset.Zero,
drawPath(petalPath(x, y, sizePx), color = p.color.copy(alpha = alpha * 0.85f)) srcSize = IntSize(img.width, img.height),
} dstOffset = IntOffset((x - sizePx / 2f).toInt(), (y - sizePx / 2f).toInt()),
} dstSize = IntSize(sizePx, sizePx),
} alpha = alpha
/** 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
) )
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.material3.Button
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import app.closer.ui.components.CelebrationOverlay import app.closer.ui.components.CelebrationOverlay
import app.closer.ui.components.IllustrationPlaceholder
import app.closer.ui.components.CategoryGlyph import app.closer.ui.components.CategoryGlyph
import app.closer.ui.components.CloserActionButton import app.closer.ui.components.CloserActionButton
import app.closer.ui.components.CloserButtonStyle 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.ErrorState
import app.closer.ui.components.LoadingState import app.closer.ui.components.LoadingState
import app.closer.ui.questions.displayCategoryName 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.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.closerCardColor import app.closer.ui.theme.closerCardColor
@ -295,7 +297,9 @@ private fun HomeContent(
HomeHeader( HomeHeader(
partnerName = state.partnerName, partnerName = state.partnerName,
streakCount = state.streakCount, streakCount = state.streakCount,
isPaired = state.isPaired isPaired = state.isPaired,
unreadActivityCount = state.unreadActivityCount,
onTogether = { onNavigate(AppRoute.ACTIVITY) }
) )
if (state.isPaired) { if (state.isPaired) {
@ -443,22 +447,53 @@ private fun StreakCard(
private fun HomeHeader( private fun HomeHeader(
partnerName: String?, partnerName: String?,
streakCount: Int, streakCount: Int,
isPaired: Boolean isPaired: Boolean,
unreadActivityCount: Int = 0,
onTogether: () -> Unit = {}
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "For tonight", text = "For tonight",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
if (streakCount > 0) { 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( Text(
@ -1376,47 +1411,52 @@ fun HomeScreenPreview() {
} }
@Composable @Composable
private fun StreakMilestoneDialog( internal fun StreakMilestoneDialog(
milestone: Int, milestone: Int,
partnerName: String?, partnerName: String?,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Column( Surface(
shape = RoundedCornerShape(28.dp),
color = SettingsSoft,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(28.dp)) .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface) .padding(horizontal = 24.dp)
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// TODO(asset): replace with Image(painterResource(R.drawable.streak_milestone_badge)) Column(
// Generated badge — 1024×1024 transparent PNG, pastel palette. modifier = Modifier.padding(horizontal = 28.dp, vertical = 30.dp),
IllustrationPlaceholder( horizontalAlignment = Alignment.CenterHorizontally
assetName = "streak_milestone_badge", ) {
sizeHint = "1024×1024", Image(
modifier = Modifier.size(140.dp) painter = painterResource(R.drawable.illustration_streak_milestone),
) contentDescription = null,
Spacer(Modifier.height(16.dp)) modifier = Modifier.size(156.dp)
Text( )
text = "$milestone-day streak!", Spacer(Modifier.height(18.dp))
style = MaterialTheme.typography.headlineSmall, Text(
fontWeight = FontWeight.SemiBold, text = "$milestone nights together",
color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center fontWeight = FontWeight.SemiBold,
) color = SettingsInk,
Spacer(Modifier.height(8.dp)) textAlign = TextAlign.Center
Text( )
text = partnerName?.takeIf { it.isNotBlank() } Spacer(Modifier.height(8.dp))
?.let { "You and $it have shown up $milestone days in a row." } Text(
?: "You've shown up $milestone days in a row.", text = partnerName?.takeIf { it.isNotBlank() }
style = MaterialTheme.typography.bodyMedium, ?.let { "You and $it keep showing up for each other — $milestone nights running. The small moments are adding up to something." }
color = MaterialTheme.colorScheme.onSurfaceVariant, ?: "You keep showing up — $milestone nights running. The small moments are adding up to something.",
textAlign = TextAlign.Center style = MaterialTheme.typography.bodyMedium,
) color = SettingsMuted,
Spacer(Modifier.height(20.dp)) textAlign = TextAlign.Center
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) CelebrationOverlay(visible = true, intensity = 1.4f)

View File

@ -140,7 +140,8 @@ data class HomeUiState(
val showOutcomeFollowUpDialog: Boolean = false, val showOutcomeFollowUpDialog: Boolean = false,
val outcomeFollowUpDay: OutcomeDay? = null, val outcomeFollowUpDay: OutcomeDay? = null,
/** Non-null when a streak tier was just reached and should be celebrated. */ /** 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 @HiltViewModel
@ -159,7 +160,8 @@ class HomeViewModel @Inject constructor(
private val datePlanRepository: DatePlanRepository, private val datePlanRepository: DatePlanRepository,
private val sealedRevealManager: SealedRevealManager, private val sealedRevealManager: SealedRevealManager,
private val outcomeRepository: OutcomeRepository, private val outcomeRepository: OutcomeRepository,
private val settingsRepository: SettingsRepository private val settingsRepository: SettingsRepository,
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
@ -172,6 +174,16 @@ class HomeViewModel @Inject constructor(
loadHome() loadHome()
observeAnswers() observeAnswers()
observeCoupleState() 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() { 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.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat 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.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -35,6 +38,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -137,10 +145,19 @@ fun PairingSuccessScreen(
label = "heartScale" 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(SettingsBackgroundBrush)
.safeDrawingPadding() .safeDrawingPadding()
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 32.dp), .padding(horizontal = 32.dp),
@ -149,77 +166,80 @@ fun PairingSuccessScreen(
) { ) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
// Overlapping avatars + pulsing heart // Hero: both partners' photos, joined by a heart, springing in for impact.
Box( Box(
modifier = Modifier modifier = Modifier
.width(144.dp) .scale(heroScale)
.height(80.dp), .width(248.dp)
.height(152.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
PairAvatar( PairAvatar(
url = state.myPhotoUrl, url = state.myPhotoUrl,
modifier = Modifier modifier = Modifier
.size(80.dp) .size(142.dp)
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.zIndex(1f) .zIndex(1f)
) )
PairAvatar( PairAvatar(
url = state.partnerPhotoUrl, url = state.partnerPhotoUrl,
modifier = Modifier modifier = Modifier
.size(80.dp) .size(142.dp)
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.zIndex(1f) .zIndex(1f)
) )
Box( Box(
modifier = Modifier modifier = Modifier
.size(34.dp) .size(60.dp)
.zIndex(2f) .zIndex(2f)
.align(Alignment.Center) .align(Alignment.Center)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.padding(3.dp) .padding(5.dp)
.clip(CircleShape) .clip(CircleShape)
.background(SettingsSoft), .background(SettingsPrimary),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
Icons.Filled.Favorite, Icons.Filled.Favorite,
contentDescription = null, contentDescription = null,
tint = SettingsPrimary, tint = SettingsOnPrimary,
modifier = Modifier modifier = Modifier
.size(16.dp) .size(28.dp)
.scale(pulse) .scale(pulse)
) )
} }
} }
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(32.dp))
Text( Text(
text = if (state.myName.isNotBlank() && state.partnerName.isNotBlank()) text = "You're connected",
"${state.myName} & ${state.partnerName}" style = MaterialTheme.typography.displaySmall,
else "You're connected", fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = SettingsInk, color = SettingsInk,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(10.dp)) if (state.myName.isNotBlank() && state.partnerName.isNotBlank()) {
Spacer(Modifier.height(10.dp))
Text(
text = "${state.myName} & ${state.partnerName}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = SettingsPrimary,
textAlign = TextAlign.Center
)
}
Spacer(Modifier.height(14.dp))
Text( Text(
"You're connected.", text = "Two phones, one private space. Everything from here is just for the two of you.",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = SettingsMuted, color = SettingsMuted,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(4.dp))
Text(
"Ready to start your story together.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
@ -239,13 +259,17 @@ fun PairingSuccessScreen(
Spacer(Modifier.height(24.dp)) 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 ────────────────────────────────────────────────── // ── Shared avatar composable ──────────────────────────────────────────────────
@Composable @Composable
private fun PairAvatar(url: String, modifier: Modifier = Modifier) { private fun PairAvatar(url: String, modifier: Modifier = Modifier) {
val borderColor = SettingsSoft
val cleanUrl = url.takeIf { it.isNotBlank() } val cleanUrl = url.takeIf { it.isNotBlank() }
if (cleanUrl != null) { if (cleanUrl != null) {
AsyncImage( AsyncImage(
@ -256,22 +280,23 @@ private fun PairAvatar(url: String, modifier: Modifier = Modifier) {
error = rememberVectorPainter(Icons.Filled.Person), error = rememberVectorPainter(Icons.Filled.Person),
modifier = modifier modifier = modifier
.clip(CircleShape) .clip(CircleShape)
.border(2.5.dp, borderColor, CircleShape) .border(4.dp, Color.White, CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(SettingsSoft)
) )
} else { } else {
// No photo yet — warm pastel placeholder rather than a flat grey chip.
Box( Box(
modifier = modifier modifier = modifier
.clip(CircleShape) .clip(CircleShape)
.border(2.5.dp, borderColor, CircleShape) .border(4.dp, Color.White, CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant), .background(SettingsSoft),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
Icons.Filled.Person, Icons.Filled.Person,
contentDescription = null, contentDescription = null,
tint = SettingsMuted, tint = SettingsPrimary,
modifier = Modifier.size(36.dp) modifier = Modifier.fillMaxSize(0.5f)
) )
} }
} }

View File

@ -431,9 +431,19 @@ fun SettingsScreen(
icon = Icons.Filled.Favorite, icon = Icons.Filled.Favorite,
label = "Together", label = "Together",
subtitle = "Your shared activity, answers, and milestones", subtitle = "Your shared activity, answers, and milestones",
onClick = { onNavigate(AppRoute.ACTIVITY) } onClick = { onNavigate(AppRoute.ACTIVITY) },
badgeCount = state.unreadActivityCount
) )
SettingsSectionDivider() 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( SettingsRow(
icon = Icons.Filled.Done, icon = Icons.Filled.Done,
label = "Answer History", label = "Answer History",
@ -558,7 +568,8 @@ private fun SettingsRow(
label: String, label: String,
subtitle: String? = null, subtitle: String? = null,
onClick: () -> Unit, onClick: () -> Unit,
tint: androidx.compose.ui.graphics.Color = SettingsMuted tint: androidx.compose.ui.graphics.Color = SettingsMuted,
badgeCount: Int = 0
) { ) {
Row( Row(
modifier = Modifier 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( Icon(
Icons.AutoMirrored.Filled.ArrowForwardIos, Icons.AutoMirrored.Filled.ArrowForwardIos,
contentDescription = null, contentDescription = null,

View File

@ -44,7 +44,8 @@ data class SettingsUiState(
val outcomeBaselineDialogDue: Boolean = false, val outcomeBaselineDialogDue: Boolean = false,
val outcomeFollowUpDay: OutcomeDay? = null, val outcomeFollowUpDay: OutcomeDay? = null,
val outcomeSubmitSuccess: Boolean = false, val outcomeSubmitSuccess: Boolean = false,
val outcomeError: String? = null val outcomeError: String? = null,
val unreadActivityCount: Int = 0
) )
@HiltViewModel @HiltViewModel
@ -55,7 +56,8 @@ class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val outcomeRepository: OutcomeRepository, private val outcomeRepository: OutcomeRepository,
private val recoveryPhraseStore: RecoveryPhraseStore, private val recoveryPhraseStore: RecoveryPhraseStore,
private val pendingInviteStore: PendingInviteStore private val pendingInviteStore: PendingInviteStore,
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState()) private val _uiState = MutableStateFlow(SettingsUiState())
@ -66,6 +68,16 @@ class SettingsViewModel @Inject constructor(
init { init {
loadSettings() loadSettings()
observeThemeMode() 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() { private fun observeThemeMode() {