feat(engagement): streak milestones, celebration overlays, Together screen, avatar in notifications

This commit is contained in:
null 2026-06-23 18:23:49 -05:00
parent d4b20a9845
commit 17d7489dd8
52 changed files with 736 additions and 16 deletions

View File

@ -74,6 +74,7 @@ import app.closer.ui.settings.RelationshipSettingsScreen
import app.closer.ui.settings.SettingsScreen
import app.closer.ui.settings.SubscriptionScreen
import app.closer.ui.outcomes.YourProgressScreen
import app.closer.ui.activity.ActivityScreen
import app.closer.ui.wheel.CategoryPickerScreen
import app.closer.ui.wheel.SpinWheelScreen
import app.closer.ui.wheel.WheelCompleteScreen
@ -469,6 +470,9 @@ fun AppNavigation(
onBack = navigateBackOrHome
)
}
composable(route = AppRoute.ACTIVITY) {
ActivityScreen(onNavigate = navigateRoute)
}
}
}
}

View File

@ -54,6 +54,7 @@ object AppRoute {
const val WAITING_FOR_PARTNER = "waiting_for_partner"
const val RECOVERY = "recovery"
const val YOUR_PROGRESS = "your_progress"
const val ACTIVITY = "activity"
const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
fun pairingSuccess(coupleId: String) = "pairing_success/$coupleId"
@ -121,7 +122,8 @@ object AppRoute {
Definition(MEMORY_LANE_CAPSULE, "Memory Lane", "play"),
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play"),
Definition(RECOVERY, "Unlock Answers", "security"),
Definition(YOUR_PROGRESS, "Your Progress", "settings")
Definition(YOUR_PROGRESS, "Your Progress", "settings"),
Definition(ACTIVITY, "Together", "home")
)
val topLevelRoutes = setOf(

View File

@ -65,7 +65,8 @@ class AppMessagingService : FirebaseMessagingService() {
questionId = message.data["question_id"],
gameSessionId = message.data["game_session_id"],
capsuleId = message.data["capsule_id"],
challengeId = message.data["challenge_id"]
challengeId = message.data["challenge_id"],
avatarUrl = message.data["sender_avatar_url"]
)
)
}

View File

@ -37,6 +37,7 @@ class SettingsDataStore @Inject constructor(
private val OUTCOME_BASELINE_SHOWN_AT = longPreferencesKey("outcome_baseline_shown_at")
private val OUTCOME_LAST_PROMPTED_DAY = stringPreferencesKey("outcome_last_prompted_day")
private val BIOMETRIC_LOGIN = booleanPreferencesKey("biometric_login")
private val LAST_CELEBRATED_STREAK = intPreferencesKey("last_celebrated_streak_milestone")
override val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
AppSettings(
@ -57,10 +58,14 @@ class SettingsDataStore @Inject constructor(
outcomeReminderEnabled = prefs[OUTCOME_REMINDER_ENABLED] ?: true,
outcomeBaselineShownAt = prefs[OUTCOME_BASELINE_SHOWN_AT] ?: 0L,
outcomeLastPromptedDay = prefs[OUTCOME_LAST_PROMPTED_DAY] ?: "",
biometricLoginEnabled = prefs[BIOMETRIC_LOGIN] ?: false
biometricLoginEnabled = prefs[BIOMETRIC_LOGIN] ?: false,
lastCelebratedStreakMilestone = prefs[LAST_CELEBRATED_STREAK] ?: 0
)
}
override suspend fun setLastCelebratedStreakMilestone(milestone: Int) =
dataStore.edit { it[LAST_CELEBRATED_STREAK] = milestone }.let {}
override suspend fun setOutcomeReminderEnabled(enabled: Boolean) =
dataStore.edit { it[OUTCOME_REMINDER_ENABLED] = enabled }.let {}

View File

@ -0,0 +1,56 @@
package app.closer.data.remote
import app.closer.domain.model.ActivityItem
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.Query
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reads the user's "Together" activity feed from `users/{uid}/notification_queue`.
* The queue is written server-side (Admin SDK); the client only reads and flips `read`.
*/
@Singleton
class FirestoreActivityDataSource @Inject constructor(
private val db: FirebaseFirestore
) {
private fun queueRef(userId: String) =
db.collection("users").document(userId).collection("notification_queue")
/** Live activity feed, newest first. Errors are swallowed so the flow never crashes. */
fun observeActivity(userId: String): Flow<List<ActivityItem>> = callbackFlow {
val reg = queueRef(userId)
.orderBy("createdAt", Query.Direction.DESCENDING)
.limit(50)
.addSnapshotListener { snap, err ->
// Resolve to an empty feed on error (e.g. rules) rather than hanging on loading.
if (err != null) { trySend(emptyList()); return@addSnapshotListener }
if (snap == null) return@addSnapshotListener
trySend(snap.documents.map { d ->
ActivityItem(
id = d.id,
type = d.getString("type") ?: "",
title = d.getString("title") ?: "",
body = d.getString("body") ?: "",
read = d.getBoolean("read") ?: false,
createdAt = d.getTimestamp("createdAt")?.toDate()?.time
?: (d.getLong("createdAt") ?: 0L)
)
})
}
awaitClose { reg.remove() }
}
/** Marks every unread item read. Best-effort. */
suspend fun markAllRead(userId: String) {
val unread = queueRef(userId).whereEqualTo("read", false).get().await()
if (unread.isEmpty) return
val batch = db.batch()
unread.documents.forEach { batch.update(it.reference, "read", true) }
batch.commit().await()
}
}

View File

@ -0,0 +1,15 @@
package app.closer.domain.model
/**
* One entry in the "Together" activity feed, mirrored from the server-written
* `users/{uid}/notification_queue` collection. Titles/bodies are static, partner-safe
* copy never decrypted answer content.
*/
data class ActivityItem(
val id: String = "",
val type: String = "",
val title: String = "",
val body: String = "",
val read: Boolean = false,
val createdAt: Long = 0L
)

View File

@ -26,7 +26,9 @@ data class AppSettings(
val outcomeReminderEnabled: Boolean = true,
val outcomeBaselineShownAt: Long = 0L,
val outcomeLastPromptedDay: String = "",
val biometricLoginEnabled: Boolean = false
val biometricLoginEnabled: Boolean = false,
/** Highest streak milestone (7/30/100/365) already celebrated, so each fires once. */
val lastCelebratedStreakMilestone: Int = 0
)
interface SettingsRepository {
@ -43,4 +45,5 @@ interface SettingsRepository {
suspend fun markOutcomeBaselineShown()
suspend fun setOutcomeLastPromptedDay(dayKey: String)
suspend fun setBiometricLogin(enabled: Boolean)
suspend fun setLastCelebratedStreakMilestone(milestone: Int)
}

View File

@ -61,10 +61,21 @@ class PartnerNotificationManager @Inject constructor(
val route = type.routeFor(payload, coupleId)
val notificationId = collapseId(type, coupleId)
val avatar = payload.avatarUrl?.takeIf { it.isNotBlank() }?.let { loadAvatar(it) }
showNotification(notificationId, type, route)
showNotification(notificationId, type, route, avatar)
}
/** Best-effort partner-avatar load for a richer notification; null on any failure. */
private suspend fun loadAvatar(url: String): android.graphics.Bitmap? = runCatching {
val loader = coil.ImageLoader(context)
val request = coil.request.ImageRequest.Builder(context)
.data(url)
.allowHardware(false)
.build()
(loader.execute(request).drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
}.getOrNull()
/**
* Maps a remote FCM message type to a [PartnerNotificationType] and shows it.
*
@ -90,7 +101,12 @@ class PartnerNotificationManager @Inject constructor(
}
}
private fun showNotification(id: Int, type: PartnerNotificationType, route: String) {
private fun showNotification(
id: Int,
type: PartnerNotificationType,
route: String,
largeIcon: android.graphics.Bitmap? = null
) {
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return
val deepLinkUri = Uri.parse("${DEEP_LINK_SCHEME}://$DEEP_LINK_HOST/$route")
@ -113,6 +129,7 @@ class PartnerNotificationManager @Inject constructor(
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
.apply { if (largeIcon != null) setLargeIcon(largeIcon) }
.build()
NotificationManagerCompat.from(context).notify(id, notification)
@ -247,6 +264,8 @@ enum class PartnerNotificationType(
"reveal_ready" -> REVEAL_READY
"partner_started_game" -> PARTNER_STARTED_GAME
"partner_completed_part" -> PARTNER_COMPLETED_PART
// Server (onGameSessionUpdate) emits this type on game completion.
"partner_finished_game" -> PARTNER_COMPLETED_PART
"challenge_waiting" -> CHALLENGE_WAITING
"memory_capsule_unlocked" -> CAPSULE_UNLOCKED
"gentle_reminder" -> GENTLE_REMINDER
@ -270,5 +289,7 @@ data class PartnerNotificationPayload(
val questionId: String? = null,
val gameSessionId: String? = null,
val capsuleId: String? = null,
val challengeId: String? = null
val challengeId: String? = null,
/** Sender's avatar URL, used as the notification large icon when present. */
val avatarUrl: String? = null
)

View File

@ -0,0 +1,171 @@
package app.closer.ui.activity
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.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
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.model.ActivityItem
import app.closer.data.remote.FirestoreActivityDataSource
import app.closer.domain.repository.AuthRepository
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.Date
import javax.inject.Inject
data class ActivityUiState(
val isLoading: Boolean = true,
val items: List<ActivityItem> = emptyList()
)
@HiltViewModel
class ActivityViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val activityDataSource: FirestoreActivityDataSource
) : ViewModel() {
private val _uiState = MutableStateFlow(ActivityUiState())
val uiState: StateFlow<ActivityUiState> = _uiState.asStateFlow()
init {
val uid = authRepository.currentUserId
if (uid == null) {
_uiState.update { it.copy(isLoading = false) }
} else {
viewModelScope.launch {
activityDataSource.observeActivity(uid).collect { items ->
_uiState.update { it.copy(isLoading = false, items = items) }
}
}
// Opening the feed clears the unread badge — best effort.
viewModelScope.launch { runCatching { activityDataSource.markAllRead(uid) } }
}
}
}
@Composable
fun ActivityScreen(
onNavigate: (String) -> Unit = {},
viewModel: ActivityViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
SettingsSubpage(title = "Together", onBack = { onNavigate("back") }) { padding ->
if (state.isLoading) {
Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CloserHeartLoader()
}
} else if (state.items.isEmpty()) {
ActivityEmptyState(modifier = Modifier.padding(padding))
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(state.items, key = { it.id }) { item ->
ActivityRow(item)
}
}
}
}
}
@Composable
private fun ActivityRow(item: ActivityItem) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(
containerColor = if (item.read) MaterialTheme.colorScheme.surface else CloserPalette.PurpleSoft
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
if (item.body.isNotBlank()) {
Text(
text = item.body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (item.createdAt > 0L) {
Text(
text = DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(item.createdAt)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun ActivityEmptyState(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
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)
)
Text(
text = "Your shared moments will show up here",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "Answers, games, and milestones you reach together will collect here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp)
)
}
}
}

View File

@ -40,7 +40,10 @@ 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 app.closer.ui.components.CelebrationOverlay
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
@ -235,6 +238,19 @@ private fun AnswerRevealContent(
.align(Alignment.BottomCenter)
.padding(horizontal = 20.dp, vertical = 24.dp)
)
// Calm celebration the moment both partners' answers are revealed on this device.
var celebrate by remember { mutableStateOf(false) }
var celebrated by remember { mutableStateOf(false) }
LaunchedEffect(state.sealedRevealPhase, state.answer?.isRevealed, state.partnerAnswer) {
val bothRevealed = state.sealedRevealPhase == SealedRevealPhase.REVEALED ||
(state.answer?.isRevealed == true && state.partnerAnswer != null)
if (bothRevealed && !celebrated) {
celebrated = true
celebrate = true
}
}
CelebrationOverlay(visible = celebrate, onFinished = { celebrate = false })
}
}

View File

@ -0,0 +1,176 @@
package app.closer.ui.components
import android.provider.Settings
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.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 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.
*
* 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.
*
* @param intensity scales the particle count (e.g. pass the match ratio so a 5/5 game feels
* bigger than a 1/5). 1f the default count.
*/
@Composable
fun CelebrationOverlay(
visible: Boolean,
modifier: Modifier = Modifier,
intensity: Float = 1f,
onFinished: () -> Unit = {}
) {
val context = LocalContext.current
val haptics = LocalHapticFeedback.current
val reducedMotion = remember {
Settings.Global.getFloat(
context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f
) == 0f
}
val progress = remember { Animatable(0f) }
var particles by remember { mutableStateOf<List<Particle>>(emptyList()) }
LaunchedEffect(visible) {
if (!visible) return@LaunchedEffect
if (reducedMotion) {
// Respect reduced-motion: skip the animation, still fire the completion callback.
onFinished()
return@LaunchedEffect
}
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
val count = (DEFAULT_COUNT * intensity).toInt().coerceIn(MIN_COUNT, MAX_COUNT)
particles = generateParticles(count)
progress.snapTo(0f)
progress.animateTo(1f, tween(DURATION_MS, easing = LinearEasing))
onFinished()
}
if (!visible || reducedMotion || particles.isEmpty()) return
Canvas(modifier = modifier.fillMaxSize()) {
val t = progress.value
particles.forEach { p -> drawParticle(p, t) }
}
}
private data class Particle(
val startXFraction: Float,
val swayAmplitude: Float,
val swayFrequency: Float,
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
)
private fun generateParticles(count: Int): List<Particle> {
val rng = Random(System.nanoTime())
return List(count) {
Particle(
startXFraction = rng.nextFloat(),
swayAmplitude = 12f + rng.nextFloat() * 28f,
swayFrequency = 1.5f + rng.nextFloat() * 2.5f,
sizeDp = 14f + rng.nextFloat() * 16f,
delay = rng.nextFloat() * 0.35f,
rotation = rng.nextFloat() * 40f - 20f,
isHeart = rng.nextFloat() < 0.6f,
color = PARTICLE_COLORS[rng.nextInt(PARTICLE_COLORS.size)]
)
}
}
private fun DrawScope.drawParticle(p: Particle, globalProgress: Float) {
// 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
// Rise from near the bottom (88%) to the upper third (18%).
val y = size.height * (0.88f - 0.70f * local)
val baseX = size.width * p.startXFraction
val x = baseX + p.swayAmplitude * sin(local * p.swayFrequency * Math.PI).toFloat()
// Ease in quickly, hold, then fade out over the last 35%.
val alpha = when {
local < 0.12f -> local / 0.12f
local > 0.65f -> ((1f - local) / 0.35f).coerceIn(0f, 1f)
else -> 1f
}
val sizePx = p.sizeDp * density
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
)
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()
}
}
private const val DEFAULT_COUNT = 18
private const val MIN_COUNT = 8
private const val MAX_COUNT = 36
private const val DURATION_MS = 1900

View File

@ -0,0 +1,68 @@
package app.closer.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.closer.ui.theme.CloserPalette
/**
* Visual placeholder for an illustration that hasn't been generated yet.
*
* Renders an on-brand pastel card showing the expected drawable name and dimensions so the
* slot is obvious in the UI. When the real asset lands, swap this for:
* `Image(painterResource(R.drawable.<assetName>), contentDescription = null, modifier = ...)`
*
* @param assetName the intended drawable resource name (e.g. "celebration_reveal_hero").
* @param sizeHint human-readable source size (e.g. "1024×1024").
*/
@Composable
fun IllustrationPlaceholder(
assetName: String,
sizeHint: String,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(24.dp))
.background(CloserPalette.PurpleSoft),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Filled.Image,
contentDescription = null,
tint = CloserPalette.PurpleRich,
modifier = Modifier.size(40.dp)
)
Text(
text = assetName,
style = MaterialTheme.typography.labelMedium,
color = CloserPalette.PurpleDeep,
textAlign = TextAlign.Center
)
Text(
text = sizeHint,
style = MaterialTheme.typography.labelSmall,
color = CloserPalette.PurpleRich,
textAlign = TextAlign.Center
)
}
}
}

View File

@ -41,6 +41,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import app.closer.ui.components.CelebrationOverlay
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@ -436,6 +440,22 @@ fun DesireSyncScreen(
onHome = viewModel::quit
)
}
var dsCelebrate by remember { mutableStateOf(false) }
var dsCelebrated by remember { mutableStateOf(false) }
LaunchedEffect(state.phase) {
if (state.phase == DesireSyncPhase.REVEAL && !dsCelebrated) {
dsCelebrated = true
dsCelebrate = true
}
}
val dsRatio = state.questions.size.takeIf { it > 0 }
?.let { state.matches.size.toFloat() / it } ?: 0.5f
CelebrationOverlay(
visible = dsCelebrate,
intensity = 0.5f + dsRatio,
onFinished = { dsCelebrate = false }
)
}
}

View File

@ -3,6 +3,11 @@ package app.closer.ui.home
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
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
@ -160,6 +165,14 @@ fun HomeScreen(
}
}
state.streakMilestone?.let { milestone ->
StreakMilestoneDialog(
milestone = milestone,
partnerName = state.partnerName,
onDismiss = viewModel::consumeStreakMilestone
)
}
HomeContent(
state = state,
snackbarHostState = snackbarHostState,
@ -1361,3 +1374,52 @@ fun HomeScreenPreview() {
onRefresh = {}
)
}
@Composable
private fun StreakMilestoneDialog(
milestone: Int,
partnerName: String?,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Box(contentAlignment = Alignment.Center) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(28.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)
)
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")
}
}
CelebrationOverlay(visible = true, intensity = 1.4f)
}
}
}

View File

@ -138,7 +138,9 @@ data class HomeUiState(
val outcomeError: String? = null,
val showOutcomeBaselineDialog: 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. */
val streakMilestone: Int? = null
)
@HiltViewModel
@ -205,7 +207,16 @@ class HomeViewModel @Inject constructor(
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY
// Outcome check-in due-state calculation
val outcomeBaselineShownAt = settingsRepository.settings.first().outcomeBaselineShownAt
val appSettings = settingsRepository.settings.first()
val outcomeBaselineShownAt = appSettings.outcomeBaselineShownAt
// Streak milestone: celebrate each tier (7/30/100/365) exactly once.
val streak = couple?.streakCount ?: 0
val crossedMilestone = STREAK_MILESTONES
.lastOrNull { streak >= it && appSettings.lastCelebratedStreakMilestone < it }
if (crossedMilestone != null) {
runCatching { settingsRepository.setLastCelebratedStreakMilestone(crossedMilestone) }
}
val outcomes = couple?.let {
runCatching { outcomeRepository.getOutcomes(it.id) }
.onFailure { Log.w(TAG, "Could not load outcomes", it) }
@ -274,7 +285,8 @@ class HomeViewModel @Inject constructor(
hasUnlockedCapsule = hasUnlockedCapsule,
showOutcomeBaselineDialog = showBaselineDialog,
showOutcomeFollowUpDialog = followUpDay != null,
outcomeFollowUpDay = followUpDay
outcomeFollowUpDay = followUpDay,
streakMilestone = crossedMilestone ?: current.streakMilestone
).refreshDailyQuestionState().withHomeActions()
}
observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id)
@ -410,6 +422,8 @@ class HomeViewModel @Inject constructor(
fun consumeReminderSentEvent() = _uiState.update { it.copy(reminderSentEvent = false) }
fun consumeStreakMilestone() = _uiState.update { it.copy(streakMilestone = null) }
private fun observeAnswers() {
viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers ->
@ -727,5 +741,6 @@ class HomeViewModel @Inject constructor(
companion object {
private const val TAG = "HomeViewModel"
private val STREAK_MILESTONES = listOf(7, 30, 100, 365)
}
}

View File

@ -42,6 +42,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import app.closer.ui.components.CelebrationOverlay
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@ -485,6 +489,22 @@ fun HowWellScreen(
onHome = viewModel::quit
)
}
var hwCelebrate by remember { mutableStateOf(false) }
var hwCelebrated by remember { mutableStateOf(false) }
LaunchedEffect(state.phase) {
if (state.phase == HowWellPhase.COMPLETE && !hwCelebrated) {
hwCelebrated = true
hwCelebrate = true
}
}
val hwRatio = state.questions.size.takeIf { it > 0 }
?.let { state.score.toFloat() / it } ?: 0.5f
CelebrationOverlay(
visible = hwCelebrate,
intensity = 0.5f + hwRatio,
onFinished = { hwCelebrate = false }
)
}
}

View File

@ -427,6 +427,13 @@ fun SettingsScreen(
}
SettingsSection(title = "For the two of you", accent = Color(0xFFF7C8E4)) {
SettingsRow(
icon = Icons.Filled.Favorite,
label = "Together",
subtitle = "Your shared activity, answers, and milestones",
onClick = { onNavigate(AppRoute.ACTIVITY) }
)
SettingsSectionDivider()
SettingsRow(
icon = Icons.Filled.Done,
label = "Answer History",

View File

@ -47,6 +47,10 @@ 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 app.closer.ui.components.CelebrationOverlay
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -482,6 +486,22 @@ fun ThisOrThatScreen(
}
}
}
var totCelebrate by remember { mutableStateOf(false) }
var totCelebrated by remember { mutableStateOf(false) }
LaunchedEffect(state.phase) {
if (state.phase == TotPhase.REVEAL && !totCelebrated) {
totCelebrated = true
totCelebrate = true
}
}
val totRatio = state.revealCards.size.takeIf { it > 0 }
?.let { state.matchedCount.toFloat() / it } ?: 0.5f
CelebrationOverlay(
visible = totCelebrate,
intensity = 0.5f + totRatio,
onFinished = { totCelebrate = false }
)
}
}

View File

@ -34,6 +34,10 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import app.closer.ui.components.CelebrationOverlay
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@ -235,6 +239,16 @@ fun WheelCompleteScreen(
onHome = { onNavigate(AppRoute.PLAY) }
)
}
var wheelCelebrate by remember { mutableStateOf(false) }
var wheelCelebrated by remember { mutableStateOf(false) }
LaunchedEffect(state.phase) {
if (state.phase == WheelRevealPhase.REVEAL && !wheelCelebrated) {
wheelCelebrated = true
wheelCelebrate = true
}
}
CelebrationOverlay(visible = wheelCelebrate, onFinished = { wheelCelebrate = false })
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -174,7 +174,12 @@ service cloud.firestore {
// Notification queue written server-side only (Cloud Functions).
// No client read needed; the app reacts to FCM push, not this collection.
match /notification_queue/{notificationId} {
allow read, write: if false;
// Written server-side (Admin SDK bypasses rules). The owner may read their own
// activity feed and flip a notification's `read` flag — nothing else.
allow read: if isOwner(uid);
allow update: if isOwner(uid)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['read']);
allow create, delete: if false;
}
// Per-user outcome mirrors for cross-relationship progress stats.

View File

@ -45,6 +45,8 @@ export const onGameSessionUpdate = functions.firestore
const userB = await db.collection('users').doc(partnerB).get()
const partnerAName = userA.data()?.displayName ?? 'Partner A'
const partnerBName = userB.data()?.displayName ?? 'Partner B'
const avatarA = userA.data()?.photoUrl as string | undefined
const avatarB = userB.data()?.photoUrl as string | undefined
// Check if session was just created (status = "active")
const previousData = change.before.data() ?? {}
@ -60,9 +62,11 @@ export const onGameSessionUpdate = functions.firestore
const partnerId = startedBy === partnerA ? partnerB : partnerA
const partnerName = startedBy === partnerA ? partnerBName : partnerAName
const starterAvatar = startedBy === partnerA ? avatarA : avatarB
await notifyPartner(
db, messaging, partnerId, partnerName, gameType,
'partner_started_game', `${partnerName} has started a game. Tap to join!`, coupleId
'partner_started_game', `${partnerName} has started a game. Tap to join!`, coupleId,
starterAvatar
)
return
}
@ -81,16 +85,20 @@ export const onGameSessionUpdate = functions.firestore
if (partnerCompletedAt) {
await notifyPartner(
db, messaging, partnerA, partnerAName, gt,
'partner_finished_game', `${partnerBName} has finished the game. Tap to see the results!`, coupleId
'partner_finished_game', `${partnerBName} has finished the game. Tap to see the results!`, coupleId,
avatarB
)
await notifyPartner(
db, messaging, partnerB, partnerBName, gt,
'partner_finished_game', `${partnerAName} has finished the game. Tap to see the results!`, coupleId
'partner_finished_game', `${partnerAName} has finished the game. Tap to see the results!`, coupleId,
avatarA
)
} else {
const completerAvatar = completedBy === partnerA ? avatarA : avatarB
await notifyPartner(
db, messaging, partnerId, completingPartnerName, gt,
'partner_finished_game', `${completingPartnerName} has finished. Tap to continue playing!`, coupleId
'partner_finished_game', `${completingPartnerName} has finished. Tap to continue playing!`, coupleId,
completerAvatar
)
}
return
@ -108,7 +116,8 @@ async function notifyPartner(
gameType: string,
notificationType: string,
body: string,
coupleId: string
coupleId: string,
senderAvatarUrl?: string
): Promise<void> {
const notificationPayload = {
type: notificationType,
@ -165,6 +174,9 @@ async function notifyPartner(
type: notificationPayload.type,
couple_id: coupleId,
game_type: gameType,
...(senderAvatarUrl && senderAvatarUrl.length > 0
? { sender_avatar_url: senderAvatarUrl }
: {}),
},
}

View File

@ -72,6 +72,10 @@ export const onAnswerWritten = functions.firestore
const answerData = snap.data() as Partial<Record<string, unknown>>
const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : ''
// Sender (the partner who just answered) avatar — used as the notification large icon.
const senderDoc = await db.collection('users').doc(userId).get()
const senderAvatar = senderDoc.data()?.photoUrl
const payload: admin.messaging.MessagingPayload = {
notification: {
title: 'Your partner just answered!',
@ -82,6 +86,9 @@ export const onAnswerWritten = functions.firestore
couple_id: coupleId,
question_id: questionId,
date,
...(typeof senderAvatar === 'string' && senderAvatar.length > 0
? { sender_avatar_url: senderAvatar }
: {}),
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB