From 272c8997d0ba489cd3dba69c42f0b51585910d2b Mon Sep 17 00:00:00 2001 From: null Date: Tue, 23 Jun 2026 19:26:41 -0500 Subject: [PATCH] refactor(ui): celebration overlay polish, activity screen layout, home screen streak dialog, pairing success cleanup --- .../closer/core/navigation/AppNavigation.kt | 3 + .../app/closer/core/navigation/AppRoute.kt | 1 + .../remote/FirestoreActivityDataSource.kt | 11 ++ .../app/closer/ui/activity/ActivityScreen.kt | 31 ++--- .../closer/ui/answers/AnswerRevealScreen.kt | 14 +- .../ui/components/CelebrationOverlay.kt | 92 +++++-------- .../app/closer/ui/debug/ArtPreviewScreen.kt | 123 ++++++++++++++++++ .../java/app/closer/ui/home/HomeScreen.kt | 116 +++++++++++------ .../java/app/closer/ui/home/HomeViewModel.kt | 16 ++- .../closer/ui/pairing/PairingSuccessScreen.kt | 91 ++++++++----- .../app/closer/ui/settings/SettingsScreen.kt | 30 ++++- .../closer/ui/settings/SettingsViewModel.kt | 16 ++- 12 files changed, 389 insertions(+), 155 deletions(-) create mode 100644 app/src/main/java/app/closer/ui/debug/ArtPreviewScreen.kt diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 9082c5ac..564755a7 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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) + } } } } diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index 8d72087e..2f297888 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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" diff --git a/app/src/main/java/app/closer/data/remote/FirestoreActivityDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreActivityDataSource.kt index 0dadc293..3713f9b5 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreActivityDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreActivityDataSource.kt @@ -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 = 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() diff --git a/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt b/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt index 5c04624e..90251adf 100644 --- a/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt +++ b/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt index 13841110..9af96405 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt @@ -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.", diff --git a/app/src/main/java/app/closer/ui/components/CelebrationOverlay.kt b/app/src/main/java/app/closer/ui/components/CelebrationOverlay.kt index 16b5c8f0..ae797cc2 100644 --- a/app/src/main/java/app/closer/ui/components/CelebrationOverlay.kt +++ b/app/src/main/java/app/closer/ui/components/CelebrationOverlay.kt @@ -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 { @@ -103,16 +98,20 @@ private fun generateParticles(count: Int): List { 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() } } diff --git a/app/src/main/java/app/closer/ui/debug/ArtPreviewScreen.kt b/app/src/main/java/app/closer/ui/debug/ArtPreviewScreen.kt new file mode 100644 index 00000000..86f6c252 --- /dev/null +++ b/app/src/main/java/app/closer/ui/debug/ArtPreviewScreen.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 8622a425..122725ab 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -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), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxWidth() + .padding(horizontal = 24.dp) ) { - // 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) - ) - Spacer(Modifier.height(16.dp)) - Text( - text = "$milestone-day streak!", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - 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.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(20.dp)) - Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { - Text("Keep it going") + Column( + modifier = Modifier.padding(horizontal = 28.dp, vertical = 30.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.illustration_streak_milestone), + contentDescription = null, + modifier = Modifier.size(156.dp) + ) + Spacer(Modifier.height(18.dp)) + Text( + text = "$milestone nights together", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = SettingsInk, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + text = partnerName?.takeIf { it.isNotBlank() } + ?.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 = SettingsMuted, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(24.dp)) + CloserActionButton( + label = "Keep going together", + onClick = onDismiss, + modifier = Modifier.fillMaxWidth() + ) } } CelebrationOverlay(visible = true, intensity = 1.4f) diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 0fe1f129..0df68c3a 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -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() { diff --git a/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt b/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt index 584d1b41..632d2aba 100644 --- a/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt @@ -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,77 +166,80 @@ 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 ) - 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( - "You're connected.", + 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 ) - 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)) @@ -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) ) } } diff --git a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt index 91ae1042..70f8b32d 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt index 7b1bd09f..083ba353 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt @@ -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() {