feat(engagement): streak milestones, celebration overlays, Together screen, avatar in notifications
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 522 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 479 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 890 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 814 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 890 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 814 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 213 KiB |