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.SettingsScreen
|
||||||
import app.closer.ui.settings.SubscriptionScreen
|
import app.closer.ui.settings.SubscriptionScreen
|
||||||
import app.closer.ui.outcomes.YourProgressScreen
|
import app.closer.ui.outcomes.YourProgressScreen
|
||||||
|
import app.closer.ui.activity.ActivityScreen
|
||||||
import app.closer.ui.wheel.CategoryPickerScreen
|
import app.closer.ui.wheel.CategoryPickerScreen
|
||||||
import app.closer.ui.wheel.SpinWheelScreen
|
import app.closer.ui.wheel.SpinWheelScreen
|
||||||
import app.closer.ui.wheel.WheelCompleteScreen
|
import app.closer.ui.wheel.WheelCompleteScreen
|
||||||
|
|
@ -469,6 +470,9 @@ fun AppNavigation(
|
||||||
onBack = navigateBackOrHome
|
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 WAITING_FOR_PARTNER = "waiting_for_partner"
|
||||||
const val RECOVERY = "recovery"
|
const val RECOVERY = "recovery"
|
||||||
const val YOUR_PROGRESS = "your_progress"
|
const val YOUR_PROGRESS = "your_progress"
|
||||||
|
const val ACTIVITY = "activity"
|
||||||
const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
|
const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
|
||||||
|
|
||||||
fun pairingSuccess(coupleId: String) = "pairing_success/$coupleId"
|
fun pairingSuccess(coupleId: String) = "pairing_success/$coupleId"
|
||||||
|
|
@ -121,7 +122,8 @@ object AppRoute {
|
||||||
Definition(MEMORY_LANE_CAPSULE, "Memory Lane", "play"),
|
Definition(MEMORY_LANE_CAPSULE, "Memory Lane", "play"),
|
||||||
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play"),
|
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play"),
|
||||||
Definition(RECOVERY, "Unlock Answers", "security"),
|
Definition(RECOVERY, "Unlock Answers", "security"),
|
||||||
Definition(YOUR_PROGRESS, "Your Progress", "settings")
|
Definition(YOUR_PROGRESS, "Your Progress", "settings"),
|
||||||
|
Definition(ACTIVITY, "Together", "home")
|
||||||
)
|
)
|
||||||
|
|
||||||
val topLevelRoutes = setOf(
|
val topLevelRoutes = setOf(
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,8 @@ class AppMessagingService : FirebaseMessagingService() {
|
||||||
questionId = message.data["question_id"],
|
questionId = message.data["question_id"],
|
||||||
gameSessionId = message.data["game_session_id"],
|
gameSessionId = message.data["game_session_id"],
|
||||||
capsuleId = message.data["capsule_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_BASELINE_SHOWN_AT = longPreferencesKey("outcome_baseline_shown_at")
|
||||||
private val OUTCOME_LAST_PROMPTED_DAY = stringPreferencesKey("outcome_last_prompted_day")
|
private val OUTCOME_LAST_PROMPTED_DAY = stringPreferencesKey("outcome_last_prompted_day")
|
||||||
private val BIOMETRIC_LOGIN = booleanPreferencesKey("biometric_login")
|
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 ->
|
override val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
|
||||||
AppSettings(
|
AppSettings(
|
||||||
|
|
@ -57,10 +58,14 @@ class SettingsDataStore @Inject constructor(
|
||||||
outcomeReminderEnabled = prefs[OUTCOME_REMINDER_ENABLED] ?: true,
|
outcomeReminderEnabled = prefs[OUTCOME_REMINDER_ENABLED] ?: true,
|
||||||
outcomeBaselineShownAt = prefs[OUTCOME_BASELINE_SHOWN_AT] ?: 0L,
|
outcomeBaselineShownAt = prefs[OUTCOME_BASELINE_SHOWN_AT] ?: 0L,
|
||||||
outcomeLastPromptedDay = prefs[OUTCOME_LAST_PROMPTED_DAY] ?: "",
|
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) =
|
override suspend fun setOutcomeReminderEnabled(enabled: Boolean) =
|
||||||
dataStore.edit { it[OUTCOME_REMINDER_ENABLED] = enabled }.let {}
|
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 outcomeReminderEnabled: Boolean = true,
|
||||||
val outcomeBaselineShownAt: Long = 0L,
|
val outcomeBaselineShownAt: Long = 0L,
|
||||||
val outcomeLastPromptedDay: String = "",
|
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 {
|
interface SettingsRepository {
|
||||||
|
|
@ -43,4 +45,5 @@ interface SettingsRepository {
|
||||||
suspend fun markOutcomeBaselineShown()
|
suspend fun markOutcomeBaselineShown()
|
||||||
suspend fun setOutcomeLastPromptedDay(dayKey: String)
|
suspend fun setOutcomeLastPromptedDay(dayKey: String)
|
||||||
suspend fun setBiometricLogin(enabled: Boolean)
|
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 route = type.routeFor(payload, coupleId)
|
||||||
val notificationId = collapseId(type, 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.
|
* 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
|
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return
|
||||||
|
|
||||||
val deepLinkUri = Uri.parse("${DEEP_LINK_SCHEME}://$DEEP_LINK_HOST/$route")
|
val deepLinkUri = Uri.parse("${DEEP_LINK_SCHEME}://$DEEP_LINK_HOST/$route")
|
||||||
|
|
@ -113,6 +129,7 @@ class PartnerNotificationManager @Inject constructor(
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||||
|
.apply { if (largeIcon != null) setLargeIcon(largeIcon) }
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
NotificationManagerCompat.from(context).notify(id, notification)
|
NotificationManagerCompat.from(context).notify(id, notification)
|
||||||
|
|
@ -247,6 +264,8 @@ enum class PartnerNotificationType(
|
||||||
"reveal_ready" -> REVEAL_READY
|
"reveal_ready" -> REVEAL_READY
|
||||||
"partner_started_game" -> PARTNER_STARTED_GAME
|
"partner_started_game" -> PARTNER_STARTED_GAME
|
||||||
"partner_completed_part" -> PARTNER_COMPLETED_PART
|
"partner_completed_part" -> PARTNER_COMPLETED_PART
|
||||||
|
// Server (onGameSessionUpdate) emits this type on game completion.
|
||||||
|
"partner_finished_game" -> PARTNER_COMPLETED_PART
|
||||||
"challenge_waiting" -> CHALLENGE_WAITING
|
"challenge_waiting" -> CHALLENGE_WAITING
|
||||||
"memory_capsule_unlocked" -> CAPSULE_UNLOCKED
|
"memory_capsule_unlocked" -> CAPSULE_UNLOCKED
|
||||||
"gentle_reminder" -> GENTLE_REMINDER
|
"gentle_reminder" -> GENTLE_REMINDER
|
||||||
|
|
@ -270,5 +289,7 @@ data class PartnerNotificationPayload(
|
||||||
val questionId: String? = null,
|
val questionId: String? = null,
|
||||||
val gameSessionId: String? = null,
|
val gameSessionId: String? = null,
|
||||||
val capsuleId: 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.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.Alignment
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -235,6 +238,19 @@ private fun AnswerRevealContent(
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(horizontal = 20.dp, vertical = 24.dp)
|
.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.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -436,6 +440,22 @@ fun DesireSyncScreen(
|
||||||
onHome = viewModel::quit
|
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.core.navigation.AppRoute
|
||||||
import app.closer.domain.model.Question
|
import app.closer.domain.model.Question
|
||||||
import app.closer.domain.model.QuestionCategory
|
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.CategoryGlyph
|
||||||
import app.closer.ui.components.CloserActionButton
|
import app.closer.ui.components.CloserActionButton
|
||||||
import app.closer.ui.components.CloserButtonStyle
|
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(
|
HomeContent(
|
||||||
state = state,
|
state = state,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
|
|
@ -1361,3 +1374,52 @@ fun HomeScreenPreview() {
|
||||||
onRefresh = {}
|
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 outcomeError: String? = null,
|
||||||
val showOutcomeBaselineDialog: Boolean = false,
|
val showOutcomeBaselineDialog: Boolean = false,
|
||||||
val showOutcomeFollowUpDialog: 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
|
@HiltViewModel
|
||||||
|
|
@ -205,7 +207,16 @@ class HomeViewModel @Inject constructor(
|
||||||
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY
|
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY
|
||||||
|
|
||||||
// Outcome check-in due-state calculation
|
// 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 {
|
val outcomes = couple?.let {
|
||||||
runCatching { outcomeRepository.getOutcomes(it.id) }
|
runCatching { outcomeRepository.getOutcomes(it.id) }
|
||||||
.onFailure { Log.w(TAG, "Could not load outcomes", it) }
|
.onFailure { Log.w(TAG, "Could not load outcomes", it) }
|
||||||
|
|
@ -274,7 +285,8 @@ class HomeViewModel @Inject constructor(
|
||||||
hasUnlockedCapsule = hasUnlockedCapsule,
|
hasUnlockedCapsule = hasUnlockedCapsule,
|
||||||
showOutcomeBaselineDialog = showBaselineDialog,
|
showOutcomeBaselineDialog = showBaselineDialog,
|
||||||
showOutcomeFollowUpDialog = followUpDay != null,
|
showOutcomeFollowUpDialog = followUpDay != null,
|
||||||
outcomeFollowUpDay = followUpDay
|
outcomeFollowUpDay = followUpDay,
|
||||||
|
streakMilestone = crossedMilestone ?: current.streakMilestone
|
||||||
).refreshDailyQuestionState().withHomeActions()
|
).refreshDailyQuestionState().withHomeActions()
|
||||||
}
|
}
|
||||||
observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id)
|
observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id)
|
||||||
|
|
@ -410,6 +422,8 @@ class HomeViewModel @Inject constructor(
|
||||||
|
|
||||||
fun consumeReminderSentEvent() = _uiState.update { it.copy(reminderSentEvent = false) }
|
fun consumeReminderSentEvent() = _uiState.update { it.copy(reminderSentEvent = false) }
|
||||||
|
|
||||||
|
fun consumeStreakMilestone() = _uiState.update { it.copy(streakMilestone = null) }
|
||||||
|
|
||||||
private fun observeAnswers() {
|
private fun observeAnswers() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
localAnswerRepository.observeAnswers().collect { answers ->
|
localAnswerRepository.observeAnswers().collect { answers ->
|
||||||
|
|
@ -727,5 +741,6 @@ class HomeViewModel @Inject constructor(
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "HomeViewModel"
|
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.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -485,6 +489,22 @@ fun HowWellScreen(
|
||||||
onHome = viewModel::quit
|
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)) {
|
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(
|
SettingsRow(
|
||||||
icon = Icons.Filled.Done,
|
icon = Icons.Filled.Done,
|
||||||
label = "Answer History",
|
label = "Answer History",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,10 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -235,6 +239,16 @@ fun WheelCompleteScreen(
|
||||||
onHome = { onNavigate(AppRoute.PLAY) }
|
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).
|
// Notification queue written server-side only (Cloud Functions).
|
||||||
// No client read needed; the app reacts to FCM push, not this collection.
|
// No client read needed; the app reacts to FCM push, not this collection.
|
||||||
match /notification_queue/{notificationId} {
|
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.
|
// 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 userB = await db.collection('users').doc(partnerB).get()
|
||||||
const partnerAName = userA.data()?.displayName ?? 'Partner A'
|
const partnerAName = userA.data()?.displayName ?? 'Partner A'
|
||||||
const partnerBName = userB.data()?.displayName ?? 'Partner B'
|
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")
|
// Check if session was just created (status = "active")
|
||||||
const previousData = change.before.data() ?? {}
|
const previousData = change.before.data() ?? {}
|
||||||
|
|
@ -60,9 +62,11 @@ export const onGameSessionUpdate = functions.firestore
|
||||||
const partnerId = startedBy === partnerA ? partnerB : partnerA
|
const partnerId = startedBy === partnerA ? partnerB : partnerA
|
||||||
const partnerName = startedBy === partnerA ? partnerBName : partnerAName
|
const partnerName = startedBy === partnerA ? partnerBName : partnerAName
|
||||||
|
|
||||||
|
const starterAvatar = startedBy === partnerA ? avatarA : avatarB
|
||||||
await notifyPartner(
|
await notifyPartner(
|
||||||
db, messaging, partnerId, partnerName, gameType,
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -81,16 +85,20 @@ export const onGameSessionUpdate = functions.firestore
|
||||||
if (partnerCompletedAt) {
|
if (partnerCompletedAt) {
|
||||||
await notifyPartner(
|
await notifyPartner(
|
||||||
db, messaging, partnerA, partnerAName, gt,
|
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(
|
await notifyPartner(
|
||||||
db, messaging, partnerB, partnerBName, gt,
|
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 {
|
} else {
|
||||||
|
const completerAvatar = completedBy === partnerA ? avatarA : avatarB
|
||||||
await notifyPartner(
|
await notifyPartner(
|
||||||
db, messaging, partnerId, completingPartnerName, gt,
|
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
|
return
|
||||||
|
|
@ -108,7 +116,8 @@ async function notifyPartner(
|
||||||
gameType: string,
|
gameType: string,
|
||||||
notificationType: string,
|
notificationType: string,
|
||||||
body: string,
|
body: string,
|
||||||
coupleId: string
|
coupleId: string,
|
||||||
|
senderAvatarUrl?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const notificationPayload = {
|
const notificationPayload = {
|
||||||
type: notificationType,
|
type: notificationType,
|
||||||
|
|
@ -165,6 +174,9 @@ async function notifyPartner(
|
||||||
type: notificationPayload.type,
|
type: notificationPayload.type,
|
||||||
couple_id: coupleId,
|
couple_id: coupleId,
|
||||||
game_type: gameType,
|
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 answerData = snap.data() as Partial<Record<string, unknown>>
|
||||||
const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : ''
|
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 = {
|
const payload: admin.messaging.MessagingPayload = {
|
||||||
notification: {
|
notification: {
|
||||||
title: 'Your partner just answered!',
|
title: 'Your partner just answered!',
|
||||||
|
|
@ -82,6 +86,9 @@ export const onAnswerWritten = functions.firestore
|
||||||
couple_id: coupleId,
|
couple_id: coupleId,
|
||||||
question_id: questionId,
|
question_id: questionId,
|
||||||
date,
|
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 |