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 dd80052c..9082c5ac 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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) + } } } } 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 f98d9b84..8d72087e 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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( diff --git a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt index 2631917c..d60c8dd4 100644 --- a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt +++ b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt @@ -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"] ) ) } diff --git a/app/src/main/java/app/closer/data/local/SettingsDataStore.kt b/app/src/main/java/app/closer/data/local/SettingsDataStore.kt index 06d1c622..af8fb174 100644 --- a/app/src/main/java/app/closer/data/local/SettingsDataStore.kt +++ b/app/src/main/java/app/closer/data/local/SettingsDataStore.kt @@ -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 = 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 {} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreActivityDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreActivityDataSource.kt new file mode 100644 index 00000000..0dadc293 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreActivityDataSource.kt @@ -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> = 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() + } +} diff --git a/app/src/main/java/app/closer/domain/model/ActivityItem.kt b/app/src/main/java/app/closer/domain/model/ActivityItem.kt new file mode 100644 index 00000000..f6e99456 --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/ActivityItem.kt @@ -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 +) diff --git a/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt b/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt index b60f599a..f1d1bebd 100644 --- a/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt @@ -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) } diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt index ba1b19e2..a5c66382 100644 --- a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -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 ) diff --git a/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt b/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt new file mode 100644 index 00000000..5c04624e --- /dev/null +++ b/app/src/main/java/app/closer/ui/activity/ActivityScreen.kt @@ -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 = emptyList() +) + +@HiltViewModel +class ActivityViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val activityDataSource: FirestoreActivityDataSource +) : ViewModel() { + + private val _uiState = MutableStateFlow(ActivityUiState()) + val uiState: StateFlow = _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) + ) + } + } +} 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 85063b9c..13841110 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt @@ -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 }) } } diff --git a/app/src/main/java/app/closer/ui/components/CelebrationOverlay.kt b/app/src/main/java/app/closer/ui/components/CelebrationOverlay.kt new file mode 100644 index 00000000..16b5c8f0 --- /dev/null +++ b/app/src/main/java/app/closer/ui/components/CelebrationOverlay.kt @@ -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>(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 { + 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 diff --git a/app/src/main/java/app/closer/ui/components/IllustrationPlaceholder.kt b/app/src/main/java/app/closer/ui/components/IllustrationPlaceholder.kt new file mode 100644 index 00000000..e7815409 --- /dev/null +++ b/app/src/main/java/app/closer/ui/components/IllustrationPlaceholder.kt @@ -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.), 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 + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index 5185f399..6cc9cdf4 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -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 } + ) } } 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 b017fbb7..8622a425 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -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) + } + } +} 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 595ed537..0fe1f129 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -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) } } diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index 5023f1f9..919b67ee 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -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 } + ) } } 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 3b1e5097..91ae1042 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt @@ -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", diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 7f5e0aa1..e7ce1a83 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -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 } + ) } } diff --git a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt index c708d729..5ab76c49 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelCompleteScreen.kt @@ -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 }) } } diff --git a/app/src/main/res/drawable-hdpi/illustration_reveal_celebration.png b/app/src/main/res/drawable-hdpi/illustration_reveal_celebration.png new file mode 100644 index 00000000..3446045f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/illustration_reveal_celebration.png differ diff --git a/app/src/main/res/drawable-hdpi/illustration_streak_milestone.png b/app/src/main/res/drawable-hdpi/illustration_streak_milestone.png new file mode 100644 index 00000000..35b49330 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/illustration_streak_milestone.png differ diff --git a/app/src/main/res/drawable-hdpi/illustration_together_empty.png b/app/src/main/res/drawable-hdpi/illustration_together_empty.png new file mode 100644 index 00000000..306ca862 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/illustration_together_empty.png differ diff --git a/app/src/main/res/drawable-hdpi/particle_heart.png b/app/src/main/res/drawable-hdpi/particle_heart.png new file mode 100644 index 00000000..02525e5a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/particle_heart.png differ diff --git a/app/src/main/res/drawable-hdpi/particle_petal.png b/app/src/main/res/drawable-hdpi/particle_petal.png new file mode 100644 index 00000000..b26c23ef Binary files /dev/null and b/app/src/main/res/drawable-hdpi/particle_petal.png differ diff --git a/app/src/main/res/drawable-mdpi/illustration_reveal_celebration.png b/app/src/main/res/drawable-mdpi/illustration_reveal_celebration.png new file mode 100644 index 00000000..fffafad3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/illustration_reveal_celebration.png differ diff --git a/app/src/main/res/drawable-mdpi/illustration_streak_milestone.png b/app/src/main/res/drawable-mdpi/illustration_streak_milestone.png new file mode 100644 index 00000000..c4a84f14 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/illustration_streak_milestone.png differ diff --git a/app/src/main/res/drawable-mdpi/illustration_together_empty.png b/app/src/main/res/drawable-mdpi/illustration_together_empty.png new file mode 100644 index 00000000..844f2df5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/illustration_together_empty.png differ diff --git a/app/src/main/res/drawable-mdpi/particle_heart.png b/app/src/main/res/drawable-mdpi/particle_heart.png new file mode 100644 index 00000000..453f744c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/particle_heart.png differ diff --git a/app/src/main/res/drawable-mdpi/particle_petal.png b/app/src/main/res/drawable-mdpi/particle_petal.png new file mode 100644 index 00000000..d997ba2e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/particle_petal.png differ diff --git a/app/src/main/res/drawable-xhdpi/illustration_reveal_celebration.png b/app/src/main/res/drawable-xhdpi/illustration_reveal_celebration.png new file mode 100644 index 00000000..014b8a81 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/illustration_reveal_celebration.png differ diff --git a/app/src/main/res/drawable-xhdpi/illustration_streak_milestone.png b/app/src/main/res/drawable-xhdpi/illustration_streak_milestone.png new file mode 100644 index 00000000..16213516 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/illustration_streak_milestone.png differ diff --git a/app/src/main/res/drawable-xhdpi/illustration_together_empty.png b/app/src/main/res/drawable-xhdpi/illustration_together_empty.png new file mode 100644 index 00000000..361b8cbe Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/illustration_together_empty.png differ diff --git a/app/src/main/res/drawable-xhdpi/particle_heart.png b/app/src/main/res/drawable-xhdpi/particle_heart.png new file mode 100644 index 00000000..33601f23 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/particle_heart.png differ diff --git a/app/src/main/res/drawable-xhdpi/particle_petal.png b/app/src/main/res/drawable-xhdpi/particle_petal.png new file mode 100644 index 00000000..e225433b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/particle_petal.png differ diff --git a/app/src/main/res/drawable-xxhdpi/illustration_reveal_celebration.png b/app/src/main/res/drawable-xxhdpi/illustration_reveal_celebration.png new file mode 100644 index 00000000..646fbd0a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/illustration_reveal_celebration.png differ diff --git a/app/src/main/res/drawable-xxhdpi/illustration_streak_milestone.png b/app/src/main/res/drawable-xxhdpi/illustration_streak_milestone.png new file mode 100644 index 00000000..c57b6126 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/illustration_streak_milestone.png differ diff --git a/app/src/main/res/drawable-xxhdpi/illustration_together_empty.png b/app/src/main/res/drawable-xxhdpi/illustration_together_empty.png new file mode 100644 index 00000000..c2139e0f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/illustration_together_empty.png differ diff --git a/app/src/main/res/drawable-xxhdpi/particle_heart.png b/app/src/main/res/drawable-xxhdpi/particle_heart.png new file mode 100644 index 00000000..95ba4d63 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/particle_heart.png differ diff --git a/app/src/main/res/drawable-xxhdpi/particle_petal.png b/app/src/main/res/drawable-xxhdpi/particle_petal.png new file mode 100644 index 00000000..d005dff0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/particle_petal.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/illustration_reveal_celebration.png b/app/src/main/res/drawable-xxxhdpi/illustration_reveal_celebration.png new file mode 100644 index 00000000..97d4b0ce Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/illustration_reveal_celebration.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/illustration_streak_milestone.png b/app/src/main/res/drawable-xxxhdpi/illustration_streak_milestone.png new file mode 100644 index 00000000..0d8967f2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/illustration_streak_milestone.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/illustration_together_empty.png b/app/src/main/res/drawable-xxxhdpi/illustration_together_empty.png new file mode 100644 index 00000000..15f40cc5 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/illustration_together_empty.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/particle_heart.png b/app/src/main/res/drawable-xxxhdpi/particle_heart.png new file mode 100644 index 00000000..dd37eb38 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/particle_heart.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/particle_petal.png b/app/src/main/res/drawable-xxxhdpi/particle_petal.png new file mode 100644 index 00000000..af2550ec Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/particle_petal.png differ diff --git a/firestore.rules b/firestore.rules index ff0dd639..6812eb6c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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. diff --git a/functions/src/games/onGameSessionUpdate.ts b/functions/src/games/onGameSessionUpdate.ts index a4bdf489..1b432da6 100644 --- a/functions/src/games/onGameSessionUpdate.ts +++ b/functions/src/games/onGameSessionUpdate.ts @@ -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 { 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 } + : {}), }, } diff --git a/functions/src/questions/onAnswerWritten.ts b/functions/src/questions/onAnswerWritten.ts index 5375131f..8939a779 100644 --- a/functions/src/questions/onAnswerWritten.ts +++ b/functions/src/questions/onAnswerWritten.ts @@ -72,6 +72,10 @@ export const onAnswerWritten = functions.firestore const answerData = snap.data() as Partial> 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 } + : {}), }, } diff --git a/iphone/Closer/Resources/illustration-reveal-celebration.png b/iphone/Closer/Resources/illustration-reveal-celebration.png new file mode 100644 index 00000000..08877ee3 Binary files /dev/null and b/iphone/Closer/Resources/illustration-reveal-celebration.png differ diff --git a/iphone/Closer/Resources/illustration-streak-milestone.png b/iphone/Closer/Resources/illustration-streak-milestone.png new file mode 100644 index 00000000..b55a1cd7 Binary files /dev/null and b/iphone/Closer/Resources/illustration-streak-milestone.png differ diff --git a/iphone/Closer/Resources/illustration-together-empty.png b/iphone/Closer/Resources/illustration-together-empty.png new file mode 100644 index 00000000..2f4410a1 Binary files /dev/null and b/iphone/Closer/Resources/illustration-together-empty.png differ diff --git a/iphone/Closer/Resources/particle-heart.png b/iphone/Closer/Resources/particle-heart.png new file mode 100644 index 00000000..7b667a77 Binary files /dev/null and b/iphone/Closer/Resources/particle-heart.png differ diff --git a/iphone/Closer/Resources/particle-petal.png b/iphone/Closer/Resources/particle-petal.png new file mode 100644 index 00000000..8f7fc5d6 Binary files /dev/null and b/iphone/Closer/Resources/particle-petal.png differ