Compare commits

...

32 Commits

Author SHA1 Message Date
null 8d563d4fd4 docs(date-memories): add date reflections requirement to iOS parity plan 2026-06-30 18:15:15 -05:00
null a90809bd40 docs(date-memories): update Report with R23 date memories verification 2026-06-30 18:15:13 -05:00
null a0bd5fa1ac docs(date-memories): update QAPlan with date memories and reflection coverage 2026-06-30 18:15:11 -05:00
null 1159d679b0 docs(date-memories): update glyph README with date_replay count 2026-06-30 18:15:09 -05:00
null 1ea447fcd0 docs(date-memories): add illustration_date_memories_empty source assets 2026-06-30 18:15:07 -05:00
null d2ab0da87e docs(date-memories): add glyph_date_replay source assets 2026-06-30 18:15:05 -05:00
null 02c3180ee7 feat(date-memories): add illustration_date_memories_empty (light and dark) 2026-06-30 18:15:03 -05:00
null 097140bc9d feat(date-memories): add glyph_date_replay vector drawable 2026-06-30 18:15:00 -05:00
null 602ab3a260 feat(date-memories): add date_history and date_reflections Firestore security rules 2026-06-30 18:14:56 -05:00
null 4dd60a6a4d chore(date-memories): add compiled onDateReflectionWritten 2026-06-30 18:14:54 -05:00
null 2c47c222de chore(date-memories): add compiled onDateHistoryCreated 2026-06-30 18:14:52 -05:00
null d4cedaf304 chore(date-memories): update compiled function index 2026-06-30 18:14:50 -05:00
null b7ad62054b feat(date-memories): export onDateHistoryCreated and onDateReflectionWritten from index 2026-06-30 18:14:48 -05:00
null 7a5f4e9bbd feat(date-memories): add onDateReflectionWritten Cloud Function 2026-06-30 18:14:46 -05:00
null 2eb21f42f9 feat(date-memories): add onDateHistoryCreated Cloud Function 2026-06-30 18:14:44 -05:00
null aa5ebcbcac feat(date-memories): add date_logged and date_reflection_* notification channels to PartnerNotificationManager 2026-06-30 18:14:39 -05:00
null a96be6ea07 feat(date-memories): handle date_logged and date_reflection_* notification types in AppMessagingService 2026-06-30 18:14:27 -05:00
null 47f311abda test(date-memories): add DATE_REFLECTION_PENDING priority engine tests 2026-06-30 18:14:25 -05:00
null 5375ba90a8 feat(date-memories): add date reflection nudge card to HomeScreen 2026-06-30 18:14:23 -05:00
null c056f6a7a1 feat(date-memories): add date reflection pending computation to HomeViewModel 2026-06-30 18:14:21 -05:00
null 9b5f6b4eb3 feat(date-memories): add DATE_REFLECTION_PENDING priority to HomePriorityEngine 2026-06-30 18:14:18 -05:00
null 038c1bd6e6 feat(date-memories): wire DateMemoriesScreen and DateReflectionScreen into AppNavigation 2026-06-30 18:14:16 -05:00
null 9cd9cffe23 feat(date-memories): add dateReflection route to AppRoute 2026-06-30 18:14:14 -05:00
null cf061f24f6 feat(date-memories): add date memory marking logic to DateMatchesViewModel 2026-06-30 18:14:12 -05:00
null e4f10551a0 feat(date-memories): add 'We did this' and 'Date memories' entry to DateMatchesScreen 2026-06-30 18:14:10 -05:00
null 90995cdaef feat(date-memories): add DateReflectionScreen and ViewModel 2026-06-30 18:14:08 -05:00
null 151e019a88 feat(date-memories): add DateMemoriesScreen and ViewModel 2026-06-30 18:14:03 -05:00
null f81987fa94 feat(date-memories): add DATE_HISTORY and DATE_REFLECTIONS to FirestoreCollections 2026-06-30 18:14:00 -05:00
null 540ef29041 feat(date-memories): add FirestoreDateReflectionDataSource 2026-06-30 18:13:57 -05:00
null 631064fcfe feat(date-memories): add FirestoreDateMemoryDataSource 2026-06-30 18:13:56 -05:00
null de597f6238 feat(date-memories): add DateReflection domain model 2026-06-30 18:13:48 -05:00
null 9a92b2b020 feat(date-memories): add DateMemory domain model 2026-06-30 18:13:46 -05:00
39 changed files with 1429 additions and 22 deletions

View File

@ -891,7 +891,13 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
`memory_capsule_unlocked`(scheduled → capsule) & `memory_capsule_created` (if present → Memory Lane/locked capsule) ·
`challenge_day_ready`(→ Connection Challenges) & `challenge_day_completed` (if present → challenge progress) ·
`outcome_reminder`(scheduledOutcomesReminder) · `reengagement`(reengagement/gameRetention) ·
`gentle_reminder`(sendGentleReminderCallable) · `spki`(key identity/confirm → security/key screen) ·
`gentle_reminder`(sendGentleReminderCallable) ·
`thinking_of_you`(**sendThinkingOfYouCallable** ← partner-bubble sheet "💜 Thinking of you" → partner → Home;
generic copy, **no name**; rate-limited 10/rolling-24h; **quiet hours suppresses the push but still writes the
in-app/Together record**; tapping the push → Home, not a dead-end) ·
`date_reflection_partner`/`date_reflection_ready`(**onDateReflectionWritten** → partner → the date reflection;
"your turn" when one reflects, "ready to reveal" when both; gated `notifPartnerAnswered`+quiet hours) ·
`date_logged`(**onDateHistoryCreated** → partner → reflect on the just-logged date) · `spki`(key identity/confirm → security/key screen) ·
`subscription_entitlement_changed` & `security_recovery` (if present).
- **Game-notification suite (per game):** A starts from Play hub → B gets the start/join push (if supported) → B taps
and lands on the correct join/waiting/active screen → B can join from there → A sees B joined/answered → both finish
@ -1210,6 +1216,26 @@ The non-game interactive surfaces that have no functional home (Pass B is games
- **Relationship check-ins / Your Progress (outcomes):** baseline check-in (gated to show once), 30/60/90-day
follow-ups, slider inputs persist (`submitOutcomeCallable`), the progress view renders patterns/milestones,
`scheduledOutcomesReminder` fires, "No baseline yet" → check-in dialog (C-DARK-UI-002 area). Submit + Skip both work.
- **Partner bubble → quick-actions sheet (R22):** tapping the Home partner avatar opens the bottom sheet (NOT the old
dead-end into "Together"). Verify the glance (avatar + name + "💜 N nights · together since {Mon yyyy}"; streak clause
hidden when 0); **Message** → inbox, **Together** → feed, **Your relationship** → relationship settings, header →
partner page; **💜 Thinking of you** sends the nudge (Pass E `thinking_of_you`) → "Sent 💜" + in-flight disable +
friendly rate-limit/error message on failure; **unpaired account → the bubble still opens the invite flow** (never an
empty sheet); a missing/locked partner name (E2EE key absent) shows **"Your partner"**, never ciphertext/🔒.
- **"Together" feed is actionable (R22):** rows deep-link by type (message→inbox, game→Play, capsule→Memory Lane,
challenge→Challenges, date→Date Matches, answer/reveal→Today); affection/reminder rows (`thinking_of_you`/
`gentle_reminder`/`streak`) have no deeper target and stay non-tappable; opening the feed clears the unread dot;
sent `thinking_of_you` nudges show up here.
- **Date Memories & Replay (R22) — the private→reveal loop on real dates:** on a mutual match, **"We did this"**
→ logs `date_history` (idempotent; admin shows PLAINTEXT title/category + completedAt) → opens the reflection.
Both partners answer the 3 prompts privately; **admin read of the partner's `date_reflections/.../secure/payload`
is DENIED until both have reflected** (the privacy gate, same proof as the daily question) → then both **reveal
side-by-side** (real-time, no refresh). The **Date memories** timeline (entry on Date Matches) lists completed
dates newest-first with the reflection chip (Reflect / Waiting / View); empty state shows
`illustration_date_memories_empty`. Locked-key → placeholder, never ciphertext. **Home nudge:** while a completed
date has no reflection from you, Home surfaces a **"Reflect on your date with [partner] 💭"** card
(`glyph_date_replay`) → opens the Replay timeline; it clears once you've reflected. Notifications covered in
Pass E (`date_reflection_*` / `date_logged`).
- **Bucket List:** add / check-complete / edit / delete an item; empty state; both-device sync; at rest encrypted (D1);
premium state if applicable (A).
- **Plan a Date / Date Builder:** build a plan (shape/steps) → save → **persists + the partner sees it**; date plan +

File diff suppressed because one or more lines are too long

View File

@ -67,6 +67,11 @@ Implement byte-compatible Swift crypto for every Android wire format:
read (tolerant of legacy plaintext; show a 🔒 placeholder when the key is missing) — mirror Android's
`FirestoreUserDataSource` chokepoint + the pairing/legacy **migration** and **unpair-revert**. Until this ships,
iOS shows the locked placeholder for name/gender (acceptable in dev; **not** for release).
- **⛔ Date reflections (REQUIRED before iOS launch — R22).** The new Date Memories & Replay feature adds an E2EE
`couples/{id}/date_reflections/{dateId}/answers/{uid}` collection with the same couple-key + read-gated
`secure/payload` reveal as the daily question (`date_history` is plaintext). iOS must implement the matching
reflection write/decrypt/reveal (mirror `FirestoreDateReflectionDataSource`) and the `date_reflection_*` /
`date_logged` notification types; until then iOS can't participate in date reflections.
### 2.4 Screens & features to parity (~48 + new messaging)
All routes from the refreshed audit's screen map, **including the NEW Messages experience** (inbox + conversation

View File

@ -46,6 +46,8 @@ import app.closer.ui.pairing.RecoveryScreen
import app.closer.ui.dates.DateMatchScreen
import app.closer.ui.dates.DateMatchesScreen
import app.closer.ui.dates.DateBuilderScreen
import app.closer.ui.dates.DateMemoriesScreen
import app.closer.ui.dates.DateReflectionScreen
import app.closer.ui.dates.BucketListScreen
import app.closer.ui.paywall.PaywallScreen
import app.closer.ui.play.PlayHubScreen
@ -509,6 +511,15 @@ fun AppNavigation(
composable(route = AppRoute.DATE_BUILDER) {
DateBuilderScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.DATE_MEMORIES) {
DateMemoriesScreen(onNavigate = navigateRoute)
}
composable(
route = AppRoute.DATE_REFLECTION,
arguments = listOf(navArgument("dateId") { type = NavType.StringType })
) {
DateReflectionScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.BUCKET_LIST) {
BucketListScreen(onNavigate = navigateRoute)
}

View File

@ -46,6 +46,8 @@ object AppRoute {
const val DATE_MATCH = "date_match"
const val DATE_MATCHES = "date_matches"
const val DATE_BUILDER = "date_builder"
const val DATE_MEMORIES = "date_memories"
const val DATE_REFLECTION = "date_reflection/{dateId}"
const val BUCKET_LIST = "bucket_list"
const val THIS_OR_THAT = "this_or_that"
const val HOW_WELL = "how_well"
@ -195,6 +197,8 @@ object AppRoute {
fun answerReveal(questionId: String): String = "answer_reveal/${questionId.asRouteArg()}"
fun dateReflection(dateId: String): String = "date_reflection/${dateId.asRouteArg()}"
fun conversation(coupleId: String, conversationId: String): String =
"conversation/${coupleId.asRouteArg()}/${conversationId.asRouteArg()}"

View File

@ -121,6 +121,7 @@ class AppMessagingService : FirebaseMessagingService() {
gameType = message.data["game_type"],
capsuleId = message.data["capsule_id"],
challengeId = message.data["challenge_id"],
dateId = message.data["date_id"],
avatarUrl = message.data["sender_avatar_url"]
)
)

View File

@ -31,6 +31,8 @@ object FirestoreCollections {
const val DATE_MATCHES = "date_matches"
const val DATE_PLAN_PREFERENCES = "date_plan_preferences"
const val DATE_PLANS = "date_plans"
const val DATE_HISTORY = "date_history"
const val DATE_REFLECTIONS = "date_reflections"
const val BUCKET_LIST = "bucket_list"
const val DAILY_QUESTION = "daily_question"
const val CHALLENGES = "challenges"

View File

@ -0,0 +1,74 @@
package app.closer.data.remote
import app.closer.domain.model.DateMemory
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.Query
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Completed-date log for the Date Replay timeline: `couples/{coupleId}/date_history/{id}`.
* PLAINTEXT (date-idea title/category + timestamp coarse metadata, not private words; the private
* reflection lives E2EE in [FirestoreDateReflectionDataSource]). Doc id = source matchId idempotent.
*/
@Singleton
class FirestoreDateMemoryDataSource @Inject constructor(private val db: FirebaseFirestore) {
private fun historyRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId)
.collection(FirestoreCollections.Couples.DATE_HISTORY)
private fun toMemory(d: com.google.firebase.firestore.DocumentSnapshot) = DateMemory(
id = d.id,
dateIdeaId = d.getString("dateIdeaId") ?: "",
title = d.getString("title") ?: "",
category = d.getString("category") ?: "",
completedAt = d.getLong("completedAt") ?: 0L,
addedBy = d.getString("addedBy") ?: ""
)
/** Idempotent (merge on doc id = matchId — both partners marking the same date = one record). */
suspend fun markCompleted(coupleId: String, memory: DateMemory): Unit =
suspendCancellableCoroutine { cont ->
historyRef(coupleId).document(memory.id).set(
mapOf(
"dateIdeaId" to memory.dateIdeaId,
"title" to memory.title,
"category" to memory.category,
"completedAt" to memory.completedAt,
"addedBy" to memory.addedBy
),
SetOptions.merge()
).addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
}
fun observeHistory(coupleId: String): Flow<List<DateMemory>> = callbackFlow {
val reg = historyRef(coupleId)
.orderBy("completedAt", Query.Direction.DESCENDING)
.addSnapshotListener { snap, err ->
if (err != null) { close(err); return@addSnapshotListener }
trySend(snap?.documents?.map(::toMemory) ?: emptyList())
}
awaitClose { reg.remove() }
}
suspend fun getHistoryOnce(coupleId: String): List<DateMemory> =
suspendCancellableCoroutine { cont ->
historyRef(coupleId).orderBy("completedAt", Query.Direction.DESCENDING).get()
.addOnSuccessListener { snap -> cont.resume(snap.documents.map(::toMemory)) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun delete(coupleId: String, id: String): Unit =
suspendCancellableCoroutine { cont ->
historyRef(coupleId).document(id).delete()
.addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
}
}

View File

@ -0,0 +1,118 @@
package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.DateReflection
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Post-date reflections mirrors the daily-question couple-key reveal:
* `couples/{coupleId}/date_reflections/{dateId}/answers/{userId}` (+ read-gated `secure/payload`).
* "Neither partner reads the other until both reflect" is enforced by the Firestore rule, not client code.
*/
@Singleton
class FirestoreDateReflectionDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
private fun answerRef(coupleId: String, dateId: String, userId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId)
.collection(FirestoreCollections.Couples.DATE_REFLECTIONS).document(dateId)
.collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId)
private fun securePayloadRef(coupleId: String, dateId: String, userId: String) =
answerRef(coupleId, dateId, userId).collection("secure").document("payload")
/** Save my reflection: encrypted content in the gated secure subdoc + a content-free metadata doc. */
suspend fun saveReflection(coupleId: String, dateId: String, userId: String, reflection: DateReflection) {
val aead = encryptionManager.aeadFor(coupleId)
?: throw IllegalStateException("Couple key unavailable for $coupleId")
val payload = fieldEncryptor.encrypt(encode(reflection), aead, coupleId)
val now = System.currentTimeMillis()
suspendCancellableCoroutine<Unit> { cont ->
securePayloadRef(coupleId, dateId, userId).set(mapOf("encryptedPayload" to payload))
.addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
}
suspendCancellableCoroutine<Unit> { cont ->
answerRef(coupleId, dateId, userId).set(
mapOf(
"userId" to userId,
"schemaVersion" to DateReflection.SCHEMA_VERSION,
"createdAt" to now,
"updatedAt" to now,
"isRevealed" to false
)
).addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
}
}
/** True once a user has reflected (metadata doc readable by both → drives the "your turn" state). */
suspend fun hasReflected(coupleId: String, dateId: String, userId: String): Boolean =
suspendCancellableCoroutine { cont ->
answerRef(coupleId, dateId, userId).get()
.addOnSuccessListener { cont.resume(it.exists()) }
.addOnFailureListener { cont.resume(false) }
}
/** Live "has this user reflected?" — lets the reflection screen complete the reveal in real time. */
fun observeReflected(coupleId: String, dateId: String, userId: String): Flow<Boolean> = callbackFlow {
val reg = answerRef(coupleId, dateId, userId).addSnapshotListener { snap, err ->
if (err != null) return@addSnapshotListener
trySend(snap?.exists() == true)
}
awaitClose { reg.remove() }
}
/**
* Read + decrypt a user's reflection content from the gated `secure` subdoc. Returns null when the
* read is denied (partner hasn't reflected yet privacy gate) or the key is unavailable here.
*/
suspend fun decryptReflectionFor(coupleId: String, dateId: String, userId: String): DateReflection? {
val payload = runCatching {
suspendCancellableCoroutine<String?> { cont ->
securePayloadRef(coupleId, dateId, userId).get()
.addOnSuccessListener { cont.resume(it.getString("encryptedPayload")) }
.addOnFailureListener { cont.resumeWithException(it) }
}
}.getOrNull() ?: return null
val aead = encryptionManager.aeadFor(coupleId) ?: return null
val json = fieldEncryptor.decrypt(payload, aead, coupleId) ?: return null
return runCatching {
val o = JSONObject(json)
DateReflection(
dateId = dateId,
userId = userId,
favoriteMoment = o.optString("favoriteMoment"),
surprise = o.optString("surprise"),
appreciated = o.optString("appreciated"),
isRevealed = true,
schemaVersion = o.optInt("schemaVersion", DateReflection.SCHEMA_VERSION)
)
}.getOrNull()
}
/** Flag my reflection revealed — drives the optional "[partner] opened your reflection" push. */
suspend fun markRevealed(coupleId: String, dateId: String, userId: String): Unit =
suspendCancellableCoroutine { cont ->
answerRef(coupleId, dateId, userId).update(mapOf("isRevealed" to true))
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resume(Unit) } // best-effort
}
private fun encode(r: DateReflection): String = JSONObject().apply {
put("favoriteMoment", r.favoriteMoment)
put("surprise", r.surprise)
put("appreciated", r.appreciated)
put("schemaVersion", DateReflection.SCHEMA_VERSION)
}.toString()
}

View File

@ -0,0 +1,18 @@
package app.closer.domain.model
/**
* A completed date the couple logged, for the Date Replay timeline.
*
* Stored at `couples/{coupleId}/date_history/{id}` as PLAINTEXT (app content / coarse metadata the
* date-idea title + category, not the partners' private words; consistent with the metadata stance).
* The private post-date words live separately in [DateReflection] (E2EE). Doc id = the source matchId so
* "mark done" is idempotent.
*/
data class DateMemory(
val id: String = "",
val dateIdeaId: String = "",
val title: String = "",
val category: String = "",
val completedAt: Long = 0L,
val addedBy: String = ""
)

View File

@ -0,0 +1,34 @@
package app.closer.domain.model
/**
* A partner's private post-date reflection (3 prompts). The content is couple-key encrypted and lives in
* a read-gated `secure/payload` subdoc neither partner can read the other's until BOTH have reflected
* (enforced by the Firestore rule, mirroring the daily-question reveal). Privacy-native by design.
*/
data class DateReflection(
val dateId: String = "",
val userId: String = "",
val favoriteMoment: String = "",
val surprise: String = "",
val appreciated: String = "",
val isRevealed: Boolean = false,
val createdAt: Long = 0L,
val schemaVersion: Int = SCHEMA_VERSION
) {
val isEmpty: Boolean
get() = favoriteMoment.isBlank() && surprise.isBlank() && appreciated.isBlank()
companion object {
const val SCHEMA_VERSION = 1
}
}
/** Per-user reflection progress for a completed date, derived at read time (not stored). */
enum class DateReflectionState {
/** Neither this user has reflected. */
NONE,
/** This user reflected; waiting on the partner. */
AWAITING_PARTNER,
/** Both reflected — ready to reveal / revealed. */
BOTH_DONE
}

View File

@ -248,6 +248,25 @@ enum class PartnerNotificationType(
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
),
// Date Memories & Replay: partner reflected (your turn) / both reflected (reveal) / a date was logged.
DATE_REFLECTION_PARTNER(
title = "Your partner reflected on your date 💭",
body = "Add yours to reveal together.",
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
),
DATE_REFLECTION_READY(
title = "Your date reflections are ready ✨",
body = "Open to reveal them together.",
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
),
DATE_LOGGED(
title = "You went on a date 💜",
body = "Reflect on it together while it's fresh.",
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
),
DAILY_QUESTION_REMINDER(
title = "Tonight's question is waiting.",
body = "Answer together before it expires.",
@ -324,6 +343,8 @@ enum class PartnerNotificationType(
CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE
GENTLE_REMINDER -> AppRoute.DAILY_QUESTION
THINKING_OF_YOU -> AppRoute.HOME
DATE_REFLECTION_PARTNER, DATE_REFLECTION_READY, DATE_LOGGED ->
payload.dateId?.let { AppRoute.dateReflection(it) } ?: AppRoute.DATE_MEMORIES
DAILY_QUESTION_REMINDER -> AppRoute.DAILY_QUESTION
// Open the actual conversation so the partner can reply in place.
CHAT_MESSAGE -> if (payload.conversationId != null && coupleId.isNotBlank()) {
@ -356,6 +377,9 @@ enum class PartnerNotificationType(
"memory_capsule_unlocked" -> CAPSULE_UNLOCKED
"gentle_reminder" -> GENTLE_REMINDER
"thinking_of_you" -> THINKING_OF_YOU
"date_reflection_partner" -> DATE_REFLECTION_PARTNER
"date_reflection_ready" -> DATE_REFLECTION_READY
"date_logged" -> DATE_LOGGED
// Server emits both 'daily_question' (assignment) and 'daily_question_reminder' — both open Today.
"daily_question", "daily_question_reminder" -> DAILY_QUESTION_REMINDER
"chat_message" -> CHAT_MESSAGE
@ -386,6 +410,8 @@ data class PartnerNotificationPayload(
val gameType: String? = null,
val capsuleId: String? = null,
val challengeId: String? = null,
/** Completed-date id, used to deep link a date-reflection push into the reflection screen. */
val dateId: String? = null,
/** Sender's avatar URL, used as the notification large icon when present. */
val avatarUrl: String? = null
)

View File

@ -29,6 +29,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.foundation.clickable
import androidx.compose.material3.TextButton
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@ -42,8 +45,13 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.domain.model.DateCostLevel
import app.closer.domain.model.DateIdea
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.DateMatch
import app.closer.domain.model.DateMatchSuggestion
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import app.closer.domain.model.SwipeAction
import app.closer.ui.components.EmptyState
import app.closer.ui.components.ErrorState
@ -57,10 +65,17 @@ fun DateMatchesScreen(
) {
val state by viewModel.uiState.collectAsState()
// After marking a date done, open its reflection.
LaunchedEffect(Unit) {
viewModel.markedDateId.collect { dateId -> onNavigate(AppRoute.dateReflection(dateId)) }
}
DateMatchesContent(
state = state,
onRetry = viewModel::retry,
onBack = { onNavigate("back") }
onBack = { onNavigate("back") },
onMarkCompleted = viewModel::markCompleted,
onMemories = { onNavigate(AppRoute.DATE_MEMORIES) }
)
}
@ -68,7 +83,9 @@ fun DateMatchesScreen(
private fun DateMatchesContent(
state: DateMatchesUiState,
onRetry: () -> Unit,
onBack: () -> Unit
onBack: () -> Unit,
onMarkCompleted: (DateMatch) -> Unit = {},
onMemories: () -> Unit = {}
) {
Box(
modifier = Modifier
@ -104,6 +121,16 @@ private fun DateMatchesContent(
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = onMemories, contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 4.dp)) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.glyph_date_replay),
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(6.dp))
Text("Your date memories", style = MaterialTheme.typography.labelLarge)
}
}
}
@ -149,7 +176,7 @@ private fun DateMatchesContent(
)
}
items(state.mutualMatches, key = { it.id }) { match ->
MatchCard(match = match)
MatchCard(match = match, onMarkCompleted = { onMarkCompleted(match) })
}
}
@ -219,10 +246,22 @@ private fun SectionHeader(
}
@Composable
private fun MatchCard(match: DateMatch) {
private fun MatchCard(match: DateMatch, onMarkCompleted: () -> Unit = {}) {
val idea = match.dateIdea ?: return
IdeaCard(
idea = idea,
action = {
TextButton(onClick = onMarkCompleted, modifier = Modifier.fillMaxWidth()) {
Icon(
imageVector = CloserGlyphs.Heart,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFF9B1B5A)
)
Spacer(Modifier.width(6.dp))
Text("We did this", fontWeight = FontWeight.SemiBold)
}
},
badge = {
Surface(
shape = RoundedCornerShape(999.dp),
@ -286,7 +325,8 @@ private fun SuggestionCard(suggestion: DateMatchSuggestion) {
@Composable
private fun IdeaCard(
idea: DateIdea,
badge: @Composable () -> Unit
badge: @Composable () -> Unit,
action: (@Composable () -> Unit)? = null
) {
Card(
modifier = Modifier.fillMaxWidth(),
@ -348,6 +388,8 @@ private fun IdeaCard(
InfoChip(label = "Premium", emphasis = true)
}
}
action?.invoke()
}
}
}

View File

@ -3,9 +3,11 @@ package app.closer.ui.dates
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.data.remote.FirestoreDateMemoryDataSource
import app.closer.data.repository.DateIdeaSeed
import app.closer.domain.model.DateIdea
import app.closer.domain.model.DateMatch
import app.closer.domain.model.DateMemory
import app.closer.domain.model.DateMatchSuggestion
import app.closer.domain.model.DateSwipe
import app.closer.domain.model.SwipeAction
@ -16,8 +18,11 @@ import app.closer.domain.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
@ -42,16 +47,44 @@ class DateMatchesViewModel @Inject constructor(
private val repository: DateMatchRepository,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository
private val userRepository: UserRepository,
private val memoryDataSource: FirestoreDateMemoryDataSource
) : ViewModel() {
private val _uiState = MutableStateFlow(DateMatchesUiState())
val uiState: StateFlow<DateMatchesUiState> = _uiState.asStateFlow()
/** Emits the dateId to open its reflection screen right after a date is marked done. */
private val _markedDateId = MutableSharedFlow<String>(extraBufferCapacity = 1)
val markedDateId: SharedFlow<String> = _markedDateId.asSharedFlow()
init {
loadMatches()
}
/** Log a matched date as completed (idempotent) → then open its reflection. */
fun markCompleted(match: DateMatch) {
val cid = _uiState.value.coupleId ?: return
val uid = authRepository.currentUserId ?: return
val idea = match.dateIdea ?: DateIdeaSeed.byId(match.dateIdeaId)
viewModelScope.launch {
runCatching {
memoryDataSource.markCompleted(
cid,
DateMemory(
id = match.id,
dateIdeaId = match.dateIdeaId,
title = idea?.title ?: "",
category = idea?.category ?: "",
completedAt = System.currentTimeMillis(),
addedBy = uid
)
)
}.onSuccess { _markedDateId.tryEmit(match.id) }
.onFailure { Log.w(TAG, "markCompleted failed", it) }
}
}
private fun loadMatches() {
viewModelScope.launch {
_uiState.value = DateMatchesUiState(isLoading = true)

View File

@ -0,0 +1,197 @@
package app.closer.ui.dates
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.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.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.R
import app.closer.core.navigation.AppRoute
import app.closer.data.remote.FirestoreDateMemoryDataSource
import app.closer.data.remote.FirestoreDateReflectionDataSource
import app.closer.domain.model.DateMemory
import app.closer.domain.model.DateReflectionState
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.ui.components.BrandIllustration
import app.closer.ui.components.CloserCard
import app.closer.ui.components.CloserHeartLoader
import app.closer.ui.settings.SettingsSubpage
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerCardColor
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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 DateMemoryRow(val memory: DateMemory, val state: DateReflectionState)
data class DateMemoriesUiState(
val isLoading: Boolean = true,
val rows: List<DateMemoryRow> = emptyList()
)
@HiltViewModel
class DateMemoriesViewModel @Inject constructor(
private val memoryDataSource: FirestoreDateMemoryDataSource,
private val reflectionDataSource: FirestoreDateReflectionDataSource,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(DateMemoriesUiState())
val uiState: StateFlow<DateMemoriesUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
val uid = authRepository.currentUserId
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
if (uid == null || couple == null) {
_uiState.update { it.copy(isLoading = false) }
return@launch
}
val partnerId = couple.userIds.firstOrNull { it != uid }
memoryDataSource.observeHistory(couple.id).collect { memories ->
val rows = memories.map { m ->
val mine = runCatching { reflectionDataSource.hasReflected(couple.id, m.id, uid) }.getOrDefault(false)
val partner = partnerId?.let {
runCatching { reflectionDataSource.hasReflected(couple.id, m.id, it) }.getOrDefault(false)
} ?: false
val state = when {
mine && partner -> DateReflectionState.BOTH_DONE
mine -> DateReflectionState.AWAITING_PARTNER
else -> DateReflectionState.NONE
}
DateMemoryRow(m, state)
}
_uiState.update { it.copy(isLoading = false, rows = rows) }
}
}
}
}
@Composable
fun DateMemoriesScreen(
onNavigate: (String) -> Unit = {},
viewModel: DateMemoriesViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
SettingsSubpage(title = "Date memories", onBack = { onNavigate("back") }) { padding ->
when {
state.isLoading -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CloserHeartLoader() }
state.rows.isEmpty() -> DateMemoriesEmpty(Modifier.padding(padding))
else -> LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(state.rows, key = { it.memory.id }) { row ->
DateMemoryCard(row) { onNavigate(AppRoute.dateReflection(row.memory.id)) }
}
}
}
}
}
@Composable
private fun DateMemoryCard(row: DateMemoryRow, onClick: () -> Unit) {
CloserCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), containerColor = closerCardColor()) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = row.memory.title.ifBlank { "Your date" },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
val meta = listOfNotNull(
row.memory.category.takeIf { it.isNotBlank() }?.replace('_', ' '),
row.memory.completedAt.takeIf { it > 0L }
?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) }
).joinToString(" · ")
if (meta.isNotBlank()) {
Text(meta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
ReflectionChip(row.state)
}
}
}
@Composable
private fun ReflectionChip(state: DateReflectionState) {
val (label, color) = when (state) {
DateReflectionState.NONE -> "Reflect" to MaterialTheme.colorScheme.primary
DateReflectionState.AWAITING_PARTNER -> "Waiting" to CloserPalette.Gold
DateReflectionState.BOTH_DONE -> "View" to CloserPalette.Evergreen
}
Surface(shape = RoundedCornerShape(50), color = color.copy(alpha = 0.14f)) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = color
)
}
}
@Composable
private fun DateMemoriesEmpty(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
BrandIllustration(
res = R.drawable.illustration_date_memories_empty,
contentDescription = null,
modifier = Modifier.size(200.dp)
)
Text(
text = "Your dates, remembered",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "When you go on a date, mark it done and reflect together — your moments will gather here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp)
)
}
}
}

View File

@ -0,0 +1,286 @@
package app.closer.ui.dates
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.crash.CrashReporter
import app.closer.data.remote.FirestoreDateReflectionDataSource
import app.closer.domain.model.DateReflection
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.UserRepository
import app.closer.ui.components.CloserCard
import app.closer.ui.components.CloserHeartLoader
import app.closer.ui.settings.SettingsSubpage
import app.closer.ui.theme.closerCardColor
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 javax.inject.Inject
/** The three fixed post-date reflection prompts. */
private val REFLECTION_PROMPTS = listOf(
"Your favorite moment",
"What surprised you",
"What you appreciated most"
)
enum class ReflectionPhase { LOADING, EDIT, AWAITING_PARTNER, REVEALED, ERROR }
data class DateReflectionUiState(
val phase: ReflectionPhase = ReflectionPhase.LOADING,
val partnerName: String? = null,
val favoriteMoment: String = "",
val surprise: String = "",
val appreciated: String = "",
val mine: DateReflection? = null,
val partner: DateReflection? = null,
val isSaving: Boolean = false,
val error: String? = null
)
@HiltViewModel
class DateReflectionViewModel @Inject constructor(
private val reflectionDataSource: FirestoreDateReflectionDataSource,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository,
private val crashReporter: CrashReporter,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val dateId: String = savedStateHandle["dateId"] ?: ""
private val _uiState = MutableStateFlow(DateReflectionUiState())
val uiState: StateFlow<DateReflectionUiState> = _uiState.asStateFlow()
private var coupleId: String? = null
private var partnerId: String? = null
init { load() }
private fun load() {
viewModelScope.launch {
val uid = authRepository.currentUserId
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
if (uid == null || couple == null) {
_uiState.update { it.copy(phase = ReflectionPhase.ERROR, error = "Not paired.") }
return@launch
}
coupleId = couple.id
partnerId = couple.userIds.firstOrNull { it != uid }
val partnerName = partnerId?.let {
runCatching { userRepository.getUser(it)?.displayName }.getOrNull()
}?.takeIf { it.isNotBlank() } ?: "your partner"
_uiState.update { it.copy(partnerName = partnerName) }
refresh()
// Live-complete the reveal the moment the partner reflects.
partnerId?.let { pid ->
launch {
reflectionDataSource.observeReflected(couple.id, dateId, pid).collect { partnerReflected ->
if (partnerReflected && _uiState.value.phase == ReflectionPhase.AWAITING_PARTNER) refresh()
}
}
}
}
}
private suspend fun refresh() {
val cid = coupleId ?: return
val uid = authRepository.currentUserId ?: return
val pid = partnerId
val iReflected = runCatching { reflectionDataSource.hasReflected(cid, dateId, uid) }.getOrDefault(false)
val partnerReflected = pid?.let {
runCatching { reflectionDataSource.hasReflected(cid, dateId, it) }.getOrDefault(false)
} ?: false
when {
!iReflected -> _uiState.update { it.copy(phase = ReflectionPhase.EDIT) }
iReflected && !partnerReflected -> {
val mine = runCatching { reflectionDataSource.decryptReflectionFor(cid, dateId, uid) }.getOrNull()
_uiState.update { it.copy(phase = ReflectionPhase.AWAITING_PARTNER, mine = mine) }
}
else -> {
val mine = runCatching { reflectionDataSource.decryptReflectionFor(cid, dateId, uid) }.getOrNull()
val partner = pid?.let {
runCatching { reflectionDataSource.decryptReflectionFor(cid, dateId, it) }.getOrNull()
}
runCatching { reflectionDataSource.markRevealed(cid, dateId, uid) }
_uiState.update { it.copy(phase = ReflectionPhase.REVEALED, mine = mine, partner = partner) }
}
}
}
fun onFavoriteMoment(v: String) = _uiState.update { it.copy(favoriteMoment = v) }
fun onSurprise(v: String) = _uiState.update { it.copy(surprise = v) }
fun onAppreciated(v: String) = _uiState.update { it.copy(appreciated = v) }
fun save() {
val state = _uiState.value
if (state.isSaving) return
val cid = coupleId
val uid = authRepository.currentUserId
if (cid == null || uid == null) return
if (state.favoriteMoment.isBlank() && state.surprise.isBlank() && state.appreciated.isBlank()) {
_uiState.update { it.copy(error = "Add at least one reflection first.") }
return
}
_uiState.update { it.copy(isSaving = true, error = null) }
viewModelScope.launch {
runCatching {
reflectionDataSource.saveReflection(
cid, dateId, uid,
DateReflection(
dateId = dateId, userId = uid,
favoriteMoment = state.favoriteMoment.trim(),
surprise = state.surprise.trim(),
appreciated = state.appreciated.trim()
)
)
}.onFailure {
crashReporter.recordException(it)
_uiState.update { s -> s.copy(isSaving = false, error = "Couldn't save. Try again.") }
return@launch
}
_uiState.update { it.copy(isSaving = false) }
refresh()
}
}
fun dismissError() = _uiState.update { it.copy(error = null) }
}
@Composable
fun DateReflectionScreen(
onNavigate: (String) -> Unit = {},
viewModel: DateReflectionViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
SettingsSubpage(title = "Date reflection", onBack = { onNavigate("back") }) { padding ->
when (state.phase) {
ReflectionPhase.LOADING -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CloserHeartLoader() }
ReflectionPhase.ERROR -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) {
Text(state.error ?: "Something went wrong.", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
when (state.phase) {
ReflectionPhase.EDIT -> ReflectionEditor(state, viewModel)
ReflectionPhase.AWAITING_PARTNER -> AwaitingPartner(state)
ReflectionPhase.REVEALED -> RevealedReflections(state)
else -> {}
}
Spacer(Modifier.height(12.dp))
}
}
}
}
@Composable
private fun ReflectionEditor(state: DateReflectionUiState, viewModel: DateReflectionViewModel) {
Text(
"Reflect privately — your words stay sealed until you've both shared.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = state.favoriteMoment, onValueChange = viewModel::onFavoriteMoment,
label = { Text(REFLECTION_PROMPTS[0]) }, modifier = Modifier.fillMaxWidth(), minLines = 2
)
OutlinedTextField(
value = state.surprise, onValueChange = viewModel::onSurprise,
label = { Text(REFLECTION_PROMPTS[1]) }, modifier = Modifier.fillMaxWidth(), minLines = 2
)
OutlinedTextField(
value = state.appreciated, onValueChange = viewModel::onAppreciated,
label = { Text(REFLECTION_PROMPTS[2]) }, modifier = Modifier.fillMaxWidth(), minLines = 2
)
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
Button(onClick = viewModel::save, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) {
Text(if (state.isSaving) "Saving…" else "Save my reflection")
}
}
@Composable
private fun AwaitingPartner(state: DateReflectionUiState) {
Text(
"Saved privately 💜 — waiting for ${state.partnerName} to reflect. You'll reveal together.",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
state.mine?.let { ReflectionCard(title = "Your reflection", reflection = it) }
}
@Composable
private fun RevealedReflections(state: DateReflectionUiState) {
Text(
"You both reflected 💜",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
REFLECTION_PROMPTS.forEachIndexed { i, prompt ->
CloserCard(modifier = Modifier.fillMaxWidth(), containerColor = closerCardColor()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(prompt, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Text("You", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(promptValue(state.mine, i).ifBlank { "" }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
Text(state.partnerName ?: "Partner", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(promptValue(state.partner, i).ifBlank { "" }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
}
}
}
}
@Composable
private fun ReflectionCard(title: String, reflection: DateReflection) {
CloserCard(modifier = Modifier.fillMaxWidth(), containerColor = closerCardColor()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
REFLECTION_PROMPTS.forEachIndexed { i, prompt ->
val v = promptValue(reflection, i)
if (v.isNotBlank()) {
Text(prompt, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(v, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
}
}
}
}
}
private fun promptValue(r: DateReflection?, index: Int): String = when (index) {
0 -> r?.favoriteMoment.orEmpty()
1 -> r?.surprise.orEmpty()
else -> r?.appreciated.orEmpty()
}

View File

@ -19,8 +19,9 @@ package app.closer.ui.home
* 9. Weekly recap ready
* 10. Capsule unlocked
* 11. Date reminder
* 12. Suggested pack
* 13. Explore games
* 12. Date reflection pending
* 13. Suggested pack
* 14. Explore games
*
* Rules:
* - Show one primary CTA.
@ -49,6 +50,7 @@ object HomePriorityEngine {
val weeklyRecapReady: Boolean = false,
val capsuleUnlocked: Boolean = false,
val dateReminder: Boolean = false,
val dateReflectionPending: Boolean = false,
val suggestedPackAvailable: Boolean = false,
val exploreGamesAvailable: Boolean = false
)
@ -68,6 +70,9 @@ object HomePriorityEngine {
WEEKLY_RECAP_READY,
CAPSULE_UNLOCKED,
DATE_REMINDER,
// You marked a date done but haven't reflected yet. Value action in the date band: an actionable
// shared-memory prompt, ranked above the daily-question closure card.
DATE_REFLECTION_PENDING,
// You already revealed today's question — a low-priority "keep the conversation going" closure card.
DAILY_QUESTION_REVEALED,
SUGGESTED_PACK,
@ -141,6 +146,7 @@ object HomePriorityEngine {
Priority.WEEKLY_RECAP_READY -> input.weeklyRecapReady
Priority.CAPSULE_UNLOCKED -> input.capsuleUnlocked
Priority.DATE_REMINDER -> input.dateReminder
Priority.DATE_REFLECTION_PENDING -> input.dateReflectionPending
Priority.SUGGESTED_PACK -> input.suggestedPackAvailable
Priority.EXPLORE_GAMES -> input.exploreGamesAvailable
}
@ -166,7 +172,8 @@ object HomePriorityEngine {
Priority.DAILY_QUESTION_AWAITING_PARTNER,
Priority.DAILY_QUESTION_REVEALED,
Priority.WEEKLY_RECAP_READY,
Priority.DATE_REMINDER -> true
Priority.DATE_REMINDER,
Priority.DATE_REFLECTION_PENDING -> true
else -> false
}
}

View File

@ -251,6 +251,7 @@ private fun HomeCallbacks.toActionHandler(onNavigate: (String) -> Unit): (HomeAc
HomeActionTarget.Challenge -> onNavigate(AppRoute.CONNECTION_CHALLENGES)
HomeActionTarget.DatePlan -> onNavigate(AppRoute.DATE_MATCHES)
HomeActionTarget.MemoryCapsule -> onNavigate(AppRoute.MEMORY_LANE)
HomeActionTarget.DateMemories -> onNavigate(AppRoute.DATE_MEMORIES)
}
}
@ -416,6 +417,7 @@ private fun homeActionGlyph(target: HomeActionTarget): Int = when (target) {
HomeActionTarget.Challenge -> R.drawable.glyph_connection_challenge
HomeActionTarget.DatePlan -> R.drawable.glyph_date_card_heart
HomeActionTarget.MemoryCapsule -> R.drawable.glyph_memory_capsule
HomeActionTarget.DateMemories -> R.drawable.glyph_date_replay
}
@Composable
@ -715,6 +717,16 @@ private fun PartnerQuickActionsSheet(
if (state.togetherSince > 0L) add("together since ${formatMonthYear(state.togetherSince)}")
}.joinToString(" · ")
}
// Living "today" status — reflects where the couple is in the daily ritual. Hidden when there's no
// question assigned (no misleading "still open").
val todayStatus = state.dailyQuestion?.let {
when (state.dailyQuestionState) {
DailyQuestionState.BOTH_ANSWERED, DailyQuestionState.REVEALED -> "You've both answered today 💜"
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "They answered — your turn"
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "Waiting on their answer"
DailyQuestionState.UNANSWERED -> "Tonight's question is still open"
}
}
val openPartnerPage = { onNavigate(AppRoute.PARTNER_HOME); onDismiss() }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
@ -757,26 +769,36 @@ private fun PartnerQuickActionsSheet(
overflow = TextOverflow.Ellipsis
)
}
todayStatus?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 0.5.dp)
PartnerSheetAction("💜", "Thinking of you", enabled = !state.isSendingNudge, onClick = onThinkingOfYou)
PartnerSheetAction("💬", "Message") { onNavigate(AppRoute.MESSAGES); onDismiss() }
PartnerSheetAction(R.drawable.glyph_heart, "Thinking of you", enabled = !state.isSendingNudge, onClick = onThinkingOfYou)
PartnerSheetAction(R.drawable.glyph_chat, "Message") { onNavigate(AppRoute.MESSAGES); onDismiss() }
PartnerSheetAction(
"",
R.drawable.glyph_paired_cards,
"Together",
trailing = state.unreadActivityCount.takeIf { it > 0 }?.let { if (it > 9) "9+" else "$it" }
) { onNavigate(AppRoute.ACTIVITY); onDismiss() }
PartnerSheetAction("⚙️", "Your relationship") { onNavigate(AppRoute.RELATIONSHIP_SETTINGS); onDismiss() }
PartnerSheetAction(R.drawable.glyph_memory_capsule, "Our memories") { onNavigate(AppRoute.MEMORY_LANE); onDismiss() }
PartnerSheetAction(R.drawable.glyph_settings, "Your relationship") { onNavigate(AppRoute.RELATIONSHIP_SETTINGS); onDismiss() }
}
}
}
@Composable
private fun PartnerSheetAction(
emoji: String,
@DrawableRes iconRes: Int,
label: String,
enabled: Boolean = true,
trailing: String? = null,
@ -791,7 +813,12 @@ private fun PartnerSheetAction(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = emoji, style = MaterialTheme.typography.titleMedium)
HomeGlyphIcon(
resId = iconRes,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Text(
text = label,
modifier = Modifier.weight(1f),

View File

@ -70,7 +70,8 @@ enum class HomeActionTarget {
Game,
Challenge,
DatePlan,
MemoryCapsule
MemoryCapsule,
DateMemories
}
enum class HomeActionTone {
@ -158,6 +159,8 @@ data class HomeUiState(
val hasActiveChallenge: Boolean = false,
val hasUpcomingDatePlan: Boolean = false,
val hasUnlockedCapsule: Boolean = false,
// A completed date this user hasn't reflected on yet (drives the Home "reflect on your date" nudge).
val hasPendingDateReflection: Boolean = false,
val weeklyRecapReady: Boolean = false,
val reminderSentEvent: Boolean = false,
/** "Thinking of you" nudge: in-flight guard + one-shot snackbar message (success or friendly error). */
@ -192,7 +195,9 @@ class HomeViewModel @Inject constructor(
private val outcomeRepository: OutcomeRepository,
private val settingsRepository: SettingsRepository,
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource,
private val dailyQuestionResolver: app.closer.domain.usecase.DailyQuestionResolver
private val dailyQuestionResolver: app.closer.domain.usecase.DailyQuestionResolver,
private val dateMemoryDataSource: app.closer.data.remote.FirestoreDateMemoryDataSource,
private val dateReflectionDataSource: app.closer.data.remote.FirestoreDateReflectionDataSource
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
@ -289,6 +294,7 @@ class HomeViewModel @Inject constructor(
var hasActiveChallenge = false
var hasUpcomingDatePlan = false
var hasUnlockedCapsule = false
var hasPendingDateReflection = false
val coupleId = couple?.id
if (couple != null && coupleId != null && uid != null) {
coroutineScope {
@ -320,6 +326,16 @@ class HomeViewModel @Inject constructor(
.any { it.status == "sealed" && it.unlockAt in 1L..now }
}.getOrDefault(false)
}
// Pending date reflection: the most recent completed date this user hasn't
// reflected on yet. The nudge chases the latest date; older un-reflected dates
// remain reachable from the Replay timeline.
val reflectionJob = async {
runCatching {
val latest = dateMemoryDataSource.getHistoryOnce(coupleId).firstOrNull()
latest != null &&
!dateReflectionDataSource.hasReflected(coupleId, latest.id, uid)
}.getOrDefault(false)
}
val (waitingSession, waitingRoute) = gameJob.await()
hasWaitingGame = waitingSession != null
waitingGameRoute = waitingRoute
@ -327,6 +343,7 @@ class HomeViewModel @Inject constructor(
hasActiveChallenge = challengeJob.await()
hasUpcomingDatePlan = dateJob.await()
hasUnlockedCapsule = capsuleJob.await()
hasPendingDateReflection = reflectionJob.await()
}
}
@ -349,6 +366,7 @@ class HomeViewModel @Inject constructor(
hasActiveChallenge = hasActiveChallenge,
hasUpcomingDatePlan = hasUpcomingDatePlan,
hasUnlockedCapsule = hasUnlockedCapsule,
hasPendingDateReflection = hasPendingDateReflection,
showOutcomeBaselineDialog = showBaselineDialog,
showOutcomeFollowUpDialog = followUpDay != null,
outcomeFollowUpDay = followUpDay,
@ -619,6 +637,7 @@ class HomeViewModel @Inject constructor(
weeklyRecapReady = weeklyRecapReady,
capsuleUnlocked = hasUnlockedCapsule(),
dateReminder = hasUpcomingDate(),
dateReflectionPending = hasPendingDateReflection,
suggestedPackAvailable = categories.isNotEmpty(),
exploreGamesAvailable = categories.isNotEmpty()
)
@ -759,6 +778,16 @@ class HomeViewModel @Inject constructor(
tone = HomeActionTone.Ritual
)
Priority.DATE_REFLECTION_PENDING -> HomeAction(
eyebrow = "Date replay",
title = partnerName?.let { "Reflect on your date with $it 💭" }
?: "Reflect on your date 💭",
body = "Capture what the night meant to you. You'll reveal your reflections together when you're both ready.",
cta = "Add your reflection",
target = HomeActionTarget.DateMemories,
tone = HomeActionTone.Reflection
)
Priority.SUGGESTED_PACK -> categories.firstOrNull()?.let { category ->
HomeAction(
eyebrow = "Suggested pack",
@ -845,11 +874,20 @@ class HomeViewModel @Inject constructor(
)
}
if (hasPendingDateReflection) {
actions += PendingActionCard(
title = "Reflect on your date",
subtitle = "Capture the night, then reveal together.",
priority = 6,
target = HomeActionTarget.DateMemories
)
}
if (hasUnlockedCapsule()) {
actions += PendingActionCard(
title = "Capsule unlocked",
subtitle = "A saved memory is ready to open together.",
priority = 6,
priority = 7,
target = HomeActionTarget.MemoryCapsule
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M12.95,4.28c3.53,0.49 6.25,3.52 6.25,7.18c0,4 -3.24,7.24 -7.24,7.24c-1.55,0 -3,-0.49 -4.18,-1.32c-0.46,-0.32 -0.57,-0.96 -0.25,-1.42c0.32,-0.46 0.96,-0.57 1.42,-0.25c0.85,0.6 1.89,0.95 3.01,0.95c2.87,0 5.2,-2.33 5.2,-5.2c0,-2.53 -1.81,-4.64 -4.21,-5.1v1.46c0,0.58 -0.69,0.89 -1.13,0.5L8.14,5.05c-0.32,-0.29 -0.32,-0.79 0,-1.08l3.68,-3.27c0.44,-0.39 1.13,-0.08 1.13,0.5v3.08zM12,17.04c-0.16,0 -0.32,-0.06 -0.45,-0.17c-2.85,-2.52 -4.31,-4.01 -4.31,-5.95c0,-1.49 1.14,-2.62 2.61,-2.62c0.8,0 1.53,0.36 2.15,0.93c0.62,-0.57 1.35,-0.93 2.15,-0.93c1.47,0 2.61,1.13 2.61,2.62c0,1.94 -1.46,3.43 -4.31,5.95c-0.13,0.11 -0.29,0.17 -0.45,0.17z"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -318,6 +318,44 @@ class HomePriorityEngineTest {
)
}
@Test
fun `date reflection pending is a value action that surfaces as a secondary card`() {
val input = Input(
isPaired = true,
dailyQuestionUnanswered = true,
dateReflectionPending = true,
suggestedPackAvailable = true,
exploreGamesAvailable = true
)
val output = HomePriorityEngine.compute(input)
// Daily question is the hero; the pending reflection rides along as a value-action card,
// while the generic browse items (pack/explore) are filtered out of the secondary band.
assertEquals(Priority.DAILY_QUESTION_UNANSWERED, output.primary?.priority)
assertEquals(
listOf(Priority.DATE_REFLECTION_PENDING),
output.secondary.map { it.priority }
)
}
@Test
fun `date reflection pending outranks the daily question closure card`() {
val input = Input(
isPaired = true,
dateReflectionPending = true,
dailyQuestionRevealed = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.DATE_REFLECTION_PENDING, output.primary?.priority)
assertEquals(
listOf(Priority.DAILY_QUESTION_REVEALED),
output.secondary.map { it.priority }
)
}
@Test
fun `primary priority convenience returns pairing needed for default empty input`() {
assertEquals(Priority.PAIRING_NEEDED, HomePriorityEngine.primaryPriority(Input()))

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -12,7 +12,7 @@ handoff and the Android VectorDrawable XML files when wiring them into the app.
- Contact sheet: `docs/brand/generated-art/glyphs/glyph-contact-sheet.png`
## Inventory
Total: 63 glyphs.
Total: 64 glyphs.
### Closer-specific G-set
- `glyph_closer_mark`
@ -21,6 +21,7 @@ Total: 63 glyphs.
- `glyph_sealed_answer`
- `glyph_memory_capsule`
- `glyph_date_card_heart`
- `glyph_date_replay`
- `glyph_quiet_hours_moon`
- `glyph_couple_premium`
- `glyph_export_data`

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd" android:pathData="M12.95,4.28c3.53,0.49 6.25,3.52 6.25,7.18c0,4 -3.24,7.24 -7.24,7.24c-1.55,0 -3,-0.49 -4.18,-1.32c-0.46,-0.32 -0.57,-0.96 -0.25,-1.42c0.32,-0.46 0.96,-0.57 1.42,-0.25c0.85,0.6 1.89,0.95 3.01,0.95c2.87,0 5.2,-2.33 5.2,-5.2c0,-2.53 -1.81,-4.64 -4.21,-5.1v1.46c0,0.58 -0.69,0.89 -1.13,0.5L8.14,5.05c-0.32,-0.29 -0.32,-0.79 0,-1.08l3.68,-3.27c0.44,-0.39 1.13,-0.08 1.13,0.5v3.08zM12,17.04c-0.16,0 -0.32,-0.06 -0.45,-0.17c-2.85,-2.52 -4.31,-4.01 -4.31,-5.95c0,-1.49 1.14,-2.62 2.61,-2.62c0.8,0 1.53,0.36 2.15,0.93c0.62,-0.57 1.35,-0.93 2.15,-0.93c1.47,0 2.61,1.13 2.61,2.62c0,1.94 -1.46,3.43 -4.31,5.95c-0.13,0.11 -0.29,0.17 -0.45,0.17z"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 24 24" role="img" aria-label="Date replay glyph">
<path fill="#B98AF4" fill-rule="evenodd" d="M12.95,4.28c3.53,0.49 6.25,3.52 6.25,7.18c0,4 -3.24,7.24 -7.24,7.24c-1.55,0 -3,-0.49 -4.18,-1.32c-0.46,-0.32 -0.57,-0.96 -0.25,-1.42c0.32,-0.46 0.96,-0.57 1.42,-0.25c0.85,0.6 1.89,0.95 3.01,0.95c2.87,0 5.2,-2.33 5.2,-5.2c0,-2.53 -1.81,-4.64 -4.21,-5.1v1.46c0,0.58 -0.69,0.89 -1.13,0.5L8.14,5.05c-0.32,-0.29 -0.32,-0.79 0,-1.08l3.68,-3.27c0.44,-0.39 1.13,-0.08 1.13,0.5v3.08zM12,17.04c-0.16,0 -0.32,-0.06 -0.45,-0.17c-2.85,-2.52 -4.31,-4.01 -4.31,-5.95c0,-1.49 1.14,-2.62 2.61,-2.62c0.8,0 1.53,0.36 2.15,0.93c0.62,-0.57 1.35,-0.93 2.15,-0.93c1.47,0 2.61,1.13 2.61,2.62c0,1.94 -1.46,3.43 -4.31,5.95c-0.13,0.11 -0.29,0.17 -0.45,0.17z"/>
</svg>

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -643,6 +643,47 @@ service cloud.firestore {
allow delete: if isCouplesMember(coupleId);
}
// Date Replay — completed-date log. PLAINTEXT app metadata (date-idea title/category + timestamp,
// not private words; the reflection content is E2EE below). Idempotent merge on doc id = matchId.
match /date_history/{dateId} {
allow read: if isCouplesMember(coupleId);
allow create, update: if isCouplesMember(coupleId)
&& request.resource.data.keys().hasOnly(['dateIdeaId', 'title', 'category', 'completedAt', 'addedBy'])
&& request.resource.data.addedBy is string
&& request.resource.data.completedAt is number;
allow delete: if isCouplesMember(coupleId);
}
// Date reflection metadata: each user writes their own; both members read (drives "your turn").
// Mirrors daily_question/answers — encrypted content is in the read-gated `secure` subdoc below.
match /date_reflections/{dateId}/answers/{userId} {
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& request.resource.data.userId == request.auth.uid
&& request.resource.data.isRevealed == false
&& request.resource.data.keys().hasOnly(['userId', 'schemaVersion', 'createdAt', 'updatedAt', 'isRevealed']);
allow update: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& request.resource.data.userId == resource.data.userId
// Only the reveal flag may flip; the encrypted payload is immutable.
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isRevealed', 'updatedAt']);
allow delete: if false;
// Couple-key encrypted reflection content. Read-gated: you can read your PARTNER's content only
// once YOU have also reflected (the "private until both" gate). Your own content is always readable.
match /secure/{doc} {
allow read: if isCouplesMember(coupleId)
&& (request.auth.uid == userId
|| exists(/databases/$(database)/documents/couples/$(coupleId)/date_reflections/$(dateId)/answers/$(request.auth.uid)));
allow create: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& isCiphertext(request.resource.data.encryptedPayload)
&& request.resource.data.keys().hasOnly(['encryptedPayload']);
allow update, delete: if false;
}
}
// Couple Lore stores revealed answer summaries. Summary text must remain
// encrypted with the couple key; prompts/metadata can stay plaintext.
match /lore/{loreId} {

View File

@ -0,0 +1,92 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onDateHistoryCreated = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
const quietHours_1 = require("../notifications/quietHours");
/**
* Fires when a date is logged as completed (`couples/{coupleId}/date_history/{dateId}` created). Nudges
* the OTHER partner (not the one who logged it) to add their reflection so the reflectreveal loop
* starts even if the logger doesn't reflect right away. Gated on `notifPartnerAnswered` + quiet hours.
*/
exports.onDateHistoryCreated = functions.firestore
.document('couples/{coupleId}/date_history/{dateId}')
.onCreate(async (snap, context) => {
var _a, _b, _c, _d, _e;
const { coupleId, dateId } = context.params;
const db = admin.firestore();
const addedBy = (_a = snap.data()) === null || _a === void 0 ? void 0 : _a.addedBy;
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists)
return;
const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
const partnerId = userIds.find((u) => u !== addedBy);
if (!partnerId)
return;
const partnerUserDoc = await db.collection('users').doc(partnerId).get();
if (((_d = partnerUserDoc.data()) === null || _d === void 0 ? void 0 : _d.notifPartnerAnswered) === false)
return;
const title = 'You went on a date 💜';
const body = 'Reflect on it together while its fresh.';
await db.collection('users').doc(partnerId).collection('notification_queue').add({
type: 'date_logged', title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data()))
return;
const tokens = [];
const legacy = (_e = partnerUserDoc.data()) === null || _e === void 0 ? void 0 : _e.fcmToken;
if (typeof legacy === 'string' && legacy.length > 0)
tokens.push(legacy);
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get();
tokenSnap.docs.forEach((d) => {
var _a;
const t = (_a = d.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t))
tokens.push(t);
});
if (tokens.length === 0)
return;
const payload = {
notification: { title, body },
data: { type: 'date_logged', couple_id: coupleId, date_id: dateId },
};
const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, android: { notification: { channelId: 'partner_activity' } } }))));
results.forEach((r, i) => {
if (r.status === 'rejected')
console.warn(`[onDateHistoryCreated] FCM failed for ${tokens[i]}:`, r.reason);
});
});
//# sourceMappingURL=onDateHistoryCreated.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"onDateHistoryCreated.js","sourceRoot":"","sources":["../../src/dates/onDateHistoryCreated.ts"],"names":[],"mappings":"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"}

View File

@ -0,0 +1,113 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onDateReflectionWritten = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
const quietHours_1 = require("../notifications/quietHours");
/**
* Fires when a partner writes their post-date reflection
* (`couples/{coupleId}/date_reflections/{dateId}/answers/{userId}`). Notifies the OTHER partner "your
* turn to reflect" if they haven't yet, or "ready to reveal together" if both now have. Without this the
* second partner never learns to reflect and the mutual reveal stalls. Mirrors `onAnswerWritten`:
* generic copy (no decrypted content, no name the app renders the real name in-app), gated on
* `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept).
*/
exports.onDateReflectionWritten = functions.firestore
.document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}')
.onCreate(async (_snap, context) => {
var _a, _b, _c, _d, _e;
const { coupleId, dateId, userId } = context.params;
const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists)
return;
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
if (!userIds.includes(userId))
return;
const partnerId = userIds.find((u) => u !== userId);
if (!partnerId)
return;
const partnerUserDoc = await db.collection('users').doc(partnerId).get();
// Partner-activity opt-out (default on).
if (((_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.notifPartnerAnswered) === false) {
console.log(`[onDateReflectionWritten] ${partnerId} has partner notifications off`);
return;
}
// Did this complete the pair? If the recipient already reflected, both are done → reveal-ready.
const partnerReflection = await db
.collection('couples').doc(coupleId)
.collection('date_reflections').doc(dateId)
.collection('answers').doc(partnerId).get();
const bothReflected = partnerReflection.exists;
const title = bothReflected ? 'Your date reflections are ready ✨' : 'Your partner reflected on your date 💭';
const body = bothReflected ? 'Open to reveal them together.' : 'Add yours to reveal together.';
const type = bothReflected ? 'date_reflection_ready' : 'date_reflection_partner';
// In-app record (→ Together feed) — written regardless of quiet hours.
await db.collection('users').doc(partnerId).collection('notification_queue').add({
type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
// Quiet hours: keep the in-app record, suppress the disruptive push.
if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) {
console.log(`[onDateReflectionWritten] ${partnerId} in quiet hours — push suppressed (in-app kept)`);
return;
}
const senderAvatar = (_d = (await db.collection('users').doc(userId).get()).data()) === null || _d === void 0 ? void 0 : _d.photoUrl;
const tokens = [];
const legacy = (_e = partnerUserDoc.data()) === null || _e === void 0 ? void 0 : _e.fcmToken;
if (typeof legacy === 'string' && legacy.length > 0)
tokens.push(legacy);
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get();
tokenSnap.docs.forEach((d) => {
var _a;
const t = (_a = d.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t))
tokens.push(t);
});
if (tokens.length === 0)
return;
const payload = {
notification: { title, body },
data: Object.assign({ type, couple_id: coupleId, date_id: dateId }, (typeof senderAvatar === 'string' && senderAvatar.length > 0
? { sender_avatar_url: senderAvatar }
: {})),
};
const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, android: { notification: { channelId: 'partner_activity' } } }))));
results.forEach((r, i) => {
if (r.status === 'rejected')
console.warn(`[onDateReflectionWritten] FCM failed for ${tokens[i]}:`, r.reason);
});
});
//# sourceMappingURL=onDateReflectionWritten.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"onDateReflectionWritten.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionWritten.ts"],"names":[],"mappings":"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"}

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
exports.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.onDateHistoryCreated = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
const admin = __importStar(require("firebase-admin"));
// Initialize the Admin SDK once for every function in this codebase.
// Handlers call admin.firestore()/messaging() lazily at invocation time, so a
@ -67,6 +67,10 @@ var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity");
Object.defineProperty(exports, "checkDeviceIntegrity", { enumerable: true, get: function () { return checkDeviceIntegrity_1.checkDeviceIntegrity; } });
var createDateMatch_1 = require("./dates/createDateMatch");
Object.defineProperty(exports, "notifyOnDateMatch", { enumerable: true, get: function () { return createDateMatch_1.notifyOnDateMatch; } });
var onDateReflectionWritten_1 = require("./dates/onDateReflectionWritten");
Object.defineProperty(exports, "onDateReflectionWritten", { enumerable: true, get: function () { return onDateReflectionWritten_1.onDateReflectionWritten; } });
var onDateHistoryCreated_1 = require("./dates/onDateHistoryCreated");
Object.defineProperty(exports, "onDateHistoryCreated", { enumerable: true, get: function () { return onDateHistoryCreated_1.onDateHistoryCreated; } });
var assignDailyQuestion_1 = require("./questions/assignDailyQuestion");
Object.defineProperty(exports, "assignDailyQuestion", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestion; } });
Object.defineProperty(exports, "assignDailyQuestionCallable", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestionCallable; } });

View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"}

View File

@ -0,0 +1,61 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { recipientInQuietHours } from '../notifications/quietHours'
/**
* Fires when a date is logged as completed (`couples/{coupleId}/date_history/{dateId}` created). Nudges
* the OTHER partner (not the one who logged it) to add their reflection so the reflectreveal loop
* starts even if the logger doesn't reflect right away. Gated on `notifPartnerAnswered` + quiet hours.
*/
export const onDateHistoryCreated = functions.firestore
.document('couples/{coupleId}/date_history/{dateId}')
.onCreate(async (snap, context) => {
const { coupleId, dateId } = context.params as { coupleId: string; dateId: string }
const db = admin.firestore()
const addedBy = snap.data()?.addedBy as string | undefined
const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) return
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((u) => u !== addedBy)
if (!partnerId) return
const partnerUserDoc = await db.collection('users').doc(partnerId).get()
if (partnerUserDoc.data()?.notifPartnerAnswered === false) return
const title = 'You went on a date 💜'
const body = 'Reflect on it together while its fresh.'
await db.collection('users').doc(partnerId).collection('notification_queue').add({
type: 'date_logged', title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
if (recipientInQuietHours(partnerUserDoc.data())) return
const tokens: string[] = []
const legacy = partnerUserDoc.data()?.fcmToken
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy)
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get()
tokenSnap.docs.forEach((d) => {
const t = d.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
})
if (tokens.length === 0) return
const payload: admin.messaging.MessagingPayload = {
notification: { title, body },
data: { type: 'date_logged', couple_id: coupleId, date_id: dateId },
}
const results = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
...payload,
token,
android: { notification: { channelId: 'partner_activity' } },
} as admin.messaging.Message)
)
)
results.forEach((r, i) => {
if (r.status === 'rejected') console.warn(`[onDateHistoryCreated] FCM failed for ${tokens[i]}:`, r.reason)
})
})

View File

@ -0,0 +1,94 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { recipientInQuietHours } from '../notifications/quietHours'
/**
* Fires when a partner writes their post-date reflection
* (`couples/{coupleId}/date_reflections/{dateId}/answers/{userId}`). Notifies the OTHER partner "your
* turn to reflect" if they haven't yet, or "ready to reveal together" if both now have. Without this the
* second partner never learns to reflect and the mutual reveal stalls. Mirrors `onAnswerWritten`:
* generic copy (no decrypted content, no name the app renders the real name in-app), gated on
* `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept).
*/
export const onDateReflectionWritten = functions.firestore
.document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}')
.onCreate(async (_snap, context) => {
const { coupleId, dateId, userId } = context.params as {
coupleId: string
dateId: string
userId: string
}
const db = admin.firestore()
const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) return
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (!userIds.includes(userId)) return
const partnerId = userIds.find((u) => u !== userId)
if (!partnerId) return
const partnerUserDoc = await db.collection('users').doc(partnerId).get()
// Partner-activity opt-out (default on).
if (partnerUserDoc.data()?.notifPartnerAnswered === false) {
console.log(`[onDateReflectionWritten] ${partnerId} has partner notifications off`)
return
}
// Did this complete the pair? If the recipient already reflected, both are done → reveal-ready.
const partnerReflection = await db
.collection('couples').doc(coupleId)
.collection('date_reflections').doc(dateId)
.collection('answers').doc(partnerId).get()
const bothReflected = partnerReflection.exists
const title = bothReflected ? 'Your date reflections are ready ✨' : 'Your partner reflected on your date 💭'
const body = bothReflected ? 'Open to reveal them together.' : 'Add yours to reveal together.'
const type = bothReflected ? 'date_reflection_ready' : 'date_reflection_partner'
// In-app record (→ Together feed) — written regardless of quiet hours.
await db.collection('users').doc(partnerId).collection('notification_queue').add({
type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
// Quiet hours: keep the in-app record, suppress the disruptive push.
if (recipientInQuietHours(partnerUserDoc.data())) {
console.log(`[onDateReflectionWritten] ${partnerId} in quiet hours — push suppressed (in-app kept)`)
return
}
const senderAvatar = (await db.collection('users').doc(userId).get()).data()?.photoUrl
const tokens: string[] = []
const legacy = partnerUserDoc.data()?.fcmToken
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy)
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get()
tokenSnap.docs.forEach((d) => {
const t = d.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
})
if (tokens.length === 0) return
const payload: admin.messaging.MessagingPayload = {
notification: { title, body },
data: {
type,
couple_id: coupleId,
date_id: dateId,
...(typeof senderAvatar === 'string' && senderAvatar.length > 0
? { sender_avatar_url: senderAvatar }
: {}),
},
}
const results = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
...payload,
token,
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
} as admin.messaging.Message)
)
)
results.forEach((r, i) => {
if (r.status === 'rejected') console.warn(`[onDateReflectionWritten] FCM failed for ${tokens[i]}:`, r.reason)
})
})

View File

@ -25,6 +25,8 @@ export { sendStreakReminder } from './notifications/streakReminder'
export { sendReengagementReminder } from './notifications/reengagement'
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
export { notifyOnDateMatch } from './dates/createDateMatch'
export { onDateReflectionWritten } from './dates/onDateReflectionWritten'
export { onDateHistoryCreated } from './dates/onDateHistoryCreated'
export {
assignDailyQuestion,
assignDailyQuestionCallable,