Compare commits
32 Commits
28eb10f6c9
...
8d563d4fd4
| Author | SHA1 | Date |
|---|---|---|
|
|
8d563d4fd4 | |
|
|
a90809bd40 | |
|
|
a0bd5fa1ac | |
|
|
1159d679b0 | |
|
|
1ea447fcd0 | |
|
|
d2ab0da87e | |
|
|
02c3180ee7 | |
|
|
097140bc9d | |
|
|
602ab3a260 | |
|
|
4dd60a6a4d | |
|
|
2c47c222de | |
|
|
d4cedaf304 | |
|
|
b7ad62054b | |
|
|
7a5f4e9bbd | |
|
|
2eb21f42f9 | |
|
|
aa5ebcbcac | |
|
|
a96be6ea07 | |
|
|
47f311abda | |
|
|
5375ba90a8 | |
|
|
c056f6a7a1 | |
|
|
9b5f6b4eb3 | |
|
|
038c1bd6e6 | |
|
|
9cd9cffe23 | |
|
|
cf061f24f6 | |
|
|
e4f10551a0 | |
|
|
90995cdaef | |
|
|
151e019a88 | |
|
|
f81987fa94 | |
|
|
540ef29041 | |
|
|
631064fcfe | |
|
|
de597f6238 | |
|
|
9a92b2b020 |
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 = ""
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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} {
|
||||
|
|
|
|||
|
|
@ -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 reflect→reveal 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 it’s 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
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"onDateHistoryCreated.js","sourceRoot":"","sources":["../../src/dates/onDateHistoryCreated.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"onDateReflectionWritten.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionWritten.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AACvC,4DAAmE;AAEnE;;;;;;;GAOG;AACU,QAAA,uBAAuB,GAAG,SAAS,CAAC,SAAS;KACvD,QAAQ,CAAC,+DAA+D,CAAC;KACzE,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;;IACjC,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAI5C,CAAA;IACD,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,CAAC,SAAS,CAAC,MAAM;QAAE,OAAM;IAC7B,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;QAAE,OAAM;IACrC,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,MAAM,CAAC,CAAA;IACnD,IAAI,CAAC,SAAS;QAAE,OAAM;IAEtB,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IACxE,yCAAyC;IACzC,IAAI,CAAA,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,oBAAoB,MAAK,KAAK,EAAE,CAAC;QAC1D,OAAO,CAAC,GAAG,CAAC,6BAA6B,SAAS,gCAAgC,CAAC,CAAA;QACnF,OAAM;IACR,CAAC;IAED,gGAAgG;IAChG,MAAM,iBAAiB,GAAG,MAAM,EAAE;SAC/B,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC;SACnC,UAAU,CAAC,kBAAkB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC;SAC1C,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IAC7C,MAAM,aAAa,GAAG,iBAAiB,CAAC,MAAM,CAAA;IAE9C,MAAM,KAAK,GAAG,aAAa,CAAC,CAAC,CAAC,mCAAmC,CAAC,CAAC,CAAC,wCAAwC,CAAA;IAC5G,MAAM,IAAI,GAAG,aAAa,CAAC,CAAC,CAAC,+BAA+B,CAAC,CAAC,CAAC,+BAA+B,CAAA;IAC9F,MAAM,IAAI,GAAG,aAAa,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,yBAAyB,CAAA;IAEhF,uEAAuE;IACvE,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC/E,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxF,CAAC,CAAA;IAEF,qEAAqE;IACrE,IAAI,IAAA,kCAAqB,EAAC,cAAc,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;QACjD,OAAO,CAAC,GAAG,CAAC,6BAA6B,SAAS,iDAAiD,CAAC,CAAA;QACpG,OAAM;IACR,CAAC;IAED,MAAM,YAAY,GAAG,MAAA,CAAC,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAEtF,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,MAAM,GAAG,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAC9C,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;QAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IACxE,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,EAAE,CAAA;IAC3F,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE;;QAC3B,MAAM,CAAC,GAAG,MAAA,CAAC,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QACzB,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAClF,CAAC,CAAC,CAAA;IACF,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAE/B,MAAM,OAAO,GAAqC;QAChD,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;QAC7B,IAAI,kBACF,IAAI,EACJ,SAAS,EAAE,QAAQ,EACnB,OAAO,EAAE,MAAM,IACZ,CAAC,OAAO,YAAY,KAAK,QAAQ,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC;YAC7D,CAAC,CAAC,EAAE,iBAAiB,EAAE,YAAY,EAAE;YACrC,CAAC,CAAC,EAAE,CAAC,CACR;KACF,CAAA;IACD,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CACtC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,gCAClB,OAAO,KACV,KAAK,EACL,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,GAClC,CAAC,CAC9B,CACF,CAAA;IACD,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QACvB,IAAI,CAAC,CAAC,MAAM,KAAK,UAAU;YAAE,OAAO,CAAC,IAAI,CAAC,4CAA4C,MAAM,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,MAAM,CAAC,CAAA;IAC/G,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
|
||||
|
|
@ -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; } });
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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 reflect→reveal 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 it’s 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue