refactor(ui): celebration overlay polish, activity screen layout, home screen streak dialog, pairing success cleanup
This commit is contained in:
parent
17d7489dd8
commit
272c8997d0
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue