feat: date reflection reveal, UI upgrade plan, seed updates, ime-scan script

This commit is contained in:
null 2026-07-01 04:12:58 -05:00
parent 222bbd1c57
commit 896bf26b28
20 changed files with 10730 additions and 3472 deletions

View File

@ -33,7 +33,7 @@
| K — Billing & subscription lifecycle | Gate (couple-shared unlock) verified via admin toggle (Pass A) + Premium-unlock modal + `onEntitlementChanged` push live (R13/R14). **Real money path (purchase/restore/cancel→expiry-relock/refund/plan-switch) NOT tested** — needs a real device + Play sandbox. | ⚠️ **todo** — money path `blocked→needs-device`; gate ✅ |
| L — Messaging & chat (E2E) | R15: conversation render driven live — decrypt **both dirs**, attribution, timestamps, **Seen** receipt, ❤️ **reaction**, ordering, day-separators, voice-note + image bubbles, E2E composer lock glyphs; inbox decrypted previews **no `enc:` leak**; live QA→Sam send delivered; at-rest `enc:v1:`. **R18 re-verified live round-trip** (Sam→QA: received + decrypted on partner, `enc:v1:`(79) at rest, marker absent from server docs = no leak, Seen receipt). Remaining: failed-send/offline retry, delete-message, fresh image/voice send, Discuss-thread live send. | ✅ **pass (core)** — re-confirmed R18; 4 sub-items carry |
| M — Settings & account management | R15: **M-001 (quiet hours) FIXED + verified live** (server-side fail-open suppression); per-type notif toggle take-effect confirmed live (server-enforced; field flips in Firestore; toggle-off → 0 delivery); theme/DataStore persistence across relaunch ✅; biometric lock code-sound (cold-start re-lock; background-resume observation → Future.md). Remaining: edit-profile persist, unpair/delete-cascade (disruptive — deferred). **R18: M-001 re-confirmed** — toggling QH writes the client mirror (`quietHoursEnabled`/`StartMinutes 1320`/`EndMinutes 480`/`timezone`) to `users/{uid}` correctly; server suppression deployed + R15-verified. Recommend prune. | ✅ **pass (core)** — M-001 confirmed (prune next); unpair/delete deferred |
| N — Daily Q / reveal / check-ins / interactive | R15 (driven): daily-Q + **reveal both-answered gate** ✓; **Bucket List CRUD FIXED+verified (N-001)** — add(`enc:v1:`)/complete/delete/list; **Date Builder FIXED+verified (N-002)** — Create Plan → PLANNED `date_plan` (`enc:v1:`) → Home "Date coming up"; Outcomes/Your Progress code-correct (resolves coupleId, submits); Activity feed render-checked (prior). | ✅ **pass** — N-001 + N-002 fixed (pending 1 confirm) |
| N — Daily Q / reveal / check-ins / interactive | R15 (driven): daily-Q + **reveal both-answered gate** ✓; **Bucket List CRUD FIXED+verified (N-001)** — add(`enc:v1:`)/complete/delete/list; **Date Builder FIXED+verified (N-002)** — Create Plan → PLANNED `date_plan` (`enc:v1:`) → Home "Date coming up"; Outcomes/Your Progress code-correct (resolves coupleId, submits); Activity feed render-checked (prior). **R25: NEW Date Memories/Reflection feature landed (reverted-then-reinstated → slipped prior rounds); fixed 5 escaped bugs — DR-TYPING (imePadding), DR-DEEPLINK-BG (MainActivity dropped date_id), DR-FEED-ROUTE (Together `date`→DATE_MATCHES), DR-LOADER (DateMemories infinite spinner on read error), DR-LOCKED (blank dashes when vault locked); + notes field, edit-before-reveal, opened-push. NEEDS live QA pass (both devices, bg+fg notifications).** | ⚠️ **partial** — N-001/N-002 fixed; **Date Memories/Reflection = todo (new R25, needs 2-device live run)** |
| O — Release build & store readiness | **Not started.** All QA to date is on the **debug** APK. Minified release build, signing/AAB, App Check enforcement, i18n/RTL, App-Links, Play Data-Safety = pre-ship gate, not yet run. | ❌ **todo (pre-ship gate)** |
| P — Content, copy & language | R15: UI-microcopy swept (warm/inclusive; debug rows `BuildConfig.DEBUG`-gated; friendly error fallbacks; on-brand privacy copy) + **question-bank audit live: 6103 Qs — 0 empty, 0 exact dupes, 0 placeholder tokens, complete/mutually-exclusive answer configs, good type variety, consent-framed sensitive content.** No typos/off-voice/non-inclusive copy found. **R18: found+fixed P-GRAMMAR-001** — in-game wheel surfaced a subject-verb agreement error; bank scan found **13 stress-Q** from one template family where plural subjects hit a singular "{x} is …" frame ("busy weeks/health worries/… is affecting you"); fixed the 13 rows in asset `app.db` (data-only); root fix belongs in the content generator. | ✅ **pass** — copy clean; P-GRAMMAR-001 fixed (asset) + grammar-audit recommended |

View File

@ -308,6 +308,11 @@ surface and reconcile it with `ClaudeQACoverage.md`:
remote config, and any debug-only screens that should not ship.
- **Backend/rules:** inspect Firestore rules, indexes/queries, Functions triggers/callables, Storage paths, scheduled
jobs, and migrations for new data shapes or access paths.
- **⛔ Reverted-then-reinstated code (this is exactly how Date Memories/Reflection slipped R25):** diff the working
tree against the coverage matrix — `git status --short` staged **additions** (`A `) and the recent `git log` for
`Revert`/`re-add` churn. A feature that was reverted and later re-added is **new to QA even if the commits look old**;
re-enter it into the relevant passes. Cross-check that every `AppRoute`/notification type/Function trigger present in
code has a coverage row.
- **Docs update rule:** if the inventory finds a page, feature, notification, asset, state, backend path, or edge case
missing from the playbook/coverage, update `ClaudeQAPlan.md` and `ClaudeQACoverage.md` before marking the chunk done.
- **Scanner update rule:** if a manual finding is a pattern an existing scanner *should* have caught (e.g. a hardcoded
@ -724,8 +729,14 @@ Account); Paywall; Your Progress/Activity; Recovery.
- **D1 At-rest coverage:** admin-read RAW docs/objects, assert ciphertext for every private type — chat text +
`lastMessagePreview` (`enc:v1:`), chat media bytes (Tink `01 69 59 51 f0…`), answers (`sealed:v1:`/`enc:v1:`),
date plans + `date_swipes`, Memory Lane capsules, Bucket List. Also: **wrappedCoupleKey** + recovery material never
date plans + `date_swipes`, **date reflections** (`date_reflections/{dateId}/answers/{uid}/secure/payload` =
`enc:v1:`; the `date_history` metadata doc is intentionally plaintext title/category/timestamp — no private words),
Memory Lane capsules, Bucket List. Also: **wrappedCoupleKey** + recovery material never
plaintext; **invite code (KDF seed) never stored raw**; **no push payload carries private content**.
- **Date-reflection "private until both" gate + edit-seal (R25):** before you reflect, a D3 raw-API read of your
partner's `secure/payload` must be **denied**; after you reflect, it's allowed. And the author's edit-before-reveal
(secure `update`) must be **denied once the partner has reflected** (the seal holds) — verify both live via the
raw-API angle, not just the UI.
- **D2 Rules audit (static):** member-only reads, author/server-only writes, ciphertext enforced on every private
field, immutability, **no premium self-grant**, entitlements write:false; re-audit conversations/typing/reactions
+ entitlement partner-read; **no catch-all** `match /{document=**}`; list/query not enumerable; `get()`-rules don't
@ -916,7 +927,13 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
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_reflection_opened`(**onDateReflectionRevealed** → partner → "opened your reflection", after both reveal) ·
`date_logged`(**onDateHistoryCreated** → partner → reflect on the just-logged date) ·
  ⚠️ **date deep-link regression guard (R25 — Changes 2 & 3):** for every `date_*` type, tapping must
open the **exact date's reflection** in **BOTH background (OS tray) and foreground** — background nearly broke
because `MainActivity` dropped `date_id` from the payload (fell back to DATE_MEMORIES); and the in-app
**Together-feed** row for these types must route to **DATE_MEMORIES**, not DATE_MATCHES (the old
`"date" in type` substring bug). Test the feed row AND the OS notification, not just one. ·
`restore_requested`(**onRestoreRequested** → partner → the restore-consent screen; high-signal help request, NOT
suppressed by the routine partner-activity toggle, only quiet hours) · `spki`(key identity/confirm → security/key screen) ·
`subscription_entitlement_changed` & `security_recovery` (if present).
@ -1053,7 +1070,9 @@ reads, missing cache use, and slow navigation. Drive each route as a user and in
frame, and `Choreographer: Skipped N frames` / main-thread stalls in logcat. Transitions/animations stay smooth (~60fps).
- **Redundant Firestore / network reads:** count listeners/gets per screen. Switching bottom tabs and returning must
**not** refetch unchanged data; opening a screen twice must not double-read; **snapshot listeners detach on leave**
(no leaked/stacked listeners — a 2-user realtime app accumulates these fast). Watch for N+1 reads on lists.
(no leaked/stacked listeners — a 2-user realtime app accumulates these fast). Watch for N+1 reads on lists —
e.g. **DateMemories** derives each row's reflection state with per-row `hasReflected` gets; confirm they're
cached per `dateId` and not re-fetched for every history-snapshot tick (R25 improvement).
- **Memory leaks (beyond listener leaks):** add **LeakCanary** in the debug build (or take heap dumps) and navigate
in→out of every heavy screen (conversation with media, game, image viewer, Memory Lane) repeatedly — flag retained
Activities/Composables/bitmaps/Contexts. A leak that grows per navigation = bug (P2; **P1** if it OOMs).
@ -1075,6 +1094,14 @@ reads, missing cache use, and slow navigation. Drive each route as a user and in
Every **primary flow** must be usable with accessibility settings on. Enable each setting and walk the core flows
(auth, onboarding, pairing, Home, a full game, daily question + reveal, Messages, Paywall, Settings) end to end.
This is the deep home for a11y; the Pass C contrast/font spot-checks feed into it.
- **⛔ Keyboard / IME overlap (run `scripts/ime-scan.sh` FIRST — it must PASS):** the app is edge-to-edge
(`adjustResize` doesn't resize the window), so a text-input screen missing `imePadding()`/`safeDrawingPadding()`
lets the soft keyboard **cover the fields** — the exact "you can't type in Date Reflection" bug (R25). The
scanner flags any text-input file lacking IME handling (allowlisting components whose host handles it); a
MISSING hit is a bug. Then **live-verify per input screen**: focus each field with the keyboard open and
confirm the focused field stays visible and typable (don't assume — the daily flow is choice-only, so it
never exercises this). Input screens: auth (login/signup/forgot), onboarding/profile, pairing/invite/recovery,
Messages conversation, Bucket List, Date Builder, **Date Reflection**, Change/Delete/Edit in Settings, Wheel.
- **Font scaling:** `adb shell settings put system font_scale 1.3` (then 1.5, 2.0) — every primary flow stays usable:
**no clipped/overlapping text, no cut-off or hidden buttons/actions** (scroll where needed). **Acceptance: all primary
flows usable at increased font scale without clipped buttons or hidden actions.** Restore `font_scale 1.0` after.
@ -1232,6 +1259,16 @@ defect class — audit that each EXISTS:
The non-game interactive surfaces that have no functional home (Pass B is games only). Read
[Daily question lifecycle](docs/Engineering_Reference_Manual.md#daily-question-lifecycle).
- **Date Memories / Reflection (NEW — added R25; reverted-then-reinstated, so it slipped earlier rounds):**
log a date (Date Match → mark done → `date_history` row) → Home "Reflect on your date" nudge (fires for
**any** recent un-reflected date, not just the latest) → DateMemories timeline → tap a date →
DateReflectionScreen. Drive the full loop on **both** devices: type all **4** fields (favorite / surprised /
appreciated / free-form notes) — **confirm the keyboard does not cover the fields (Pass J / ime-scan)**
save → AWAITING_PARTNER (with **Edit** affordance: edits allowed only until the partner reflects) → partner
reflects → both flip to the side-by-side REVEAL. Negative/edge: neither can read the other early (Pass D
gate); a **blank/deep-linked bad `dateId`** shows an error, not a malformed write; a **locked vault**
(key unavailable) shows "Locked", not blank dashes; DateMemories **read failure** shows an error state
(not an infinite spinner); long-press a memory → **Remove** (confirm) deletes it. Notifications in Pass E.
- **Daily-question loop (the core daily ritual):** assignment (6 PM CST, `assignDailyQuestion`) → answer (each answer
type) → **both-answered gate** (neither sees the other's answer until both submit) → **mutual reveal** → per-question
**Discuss** thread (Pass L) → **Answer History****streak** increment + milestone celebration (`streak_milestone`)

120
UI_UPGRADE.md Normal file
View File

@ -0,0 +1,120 @@
# Closer UI Upgrade
This document is the focused visual upgrade plan for making Closer feel more beautiful, polished, and emotionally specific without turning it into a decorative wellness app.
The direction: keep the app calm and private, but make important relationship moments feel more intentional.
## Upgrade Priorities
### 1. Complete the Daily Question Visual Arc
The Home screen should visually move through the daily question ritual.
- `UNANSWERED`: use `illustration_tonight_partner_prompt`
- One partner answered: keep the answer-card ritual artwork
- `BOTH_ANSWERED`: use the new reveal-ready artwork
- `REVEALED`: use a calmer completed-state treatment, not the same urgency as reveal-ready
Why this matters:
The daily question is the core habit. The visuals should make the state obvious before the user reads the copy.
### 2. Add Subtle Motion to High-Emotion States
Use small, restrained motion only where it adds emotional clarity.
Best targets:
- A soft glow or pulse on the reveal-ready Home card
- Gentle fade/scale when a daily state changes
- A quiet success transition after both answers are revealed
Avoid:
- Constant decorative animation
- Bouncy motion
- Motion on dense settings, security, or history screens
Why this matters:
The app should feel alive, but still intimate and trustworthy.
### 3. Polish the Home Screen Hierarchy
The Home screen should make the next shared action unmistakable.
Improve:
- Give the primary card stronger visual priority than secondary cards
- Reduce competing surfaces around the daily question
- Make the primary CTA feel more tactile
- Keep secondary actions quieter and easier to scan
Why this matters:
Home should answer one question immediately: what should we do together next?
### 4. Make Light Theme Feel Equally Designed
Dark mode currently carries more of the mood. Light mode should feel intentional too.
Improve:
- Softer warm backgrounds
- Better contrast between cards and page surfaces
- Less clinical white space
- Light-mode versions of major illustrations where needed
Why this matters:
The product should feel premium in both themes, not like dark mode is the real design and light mode is the fallback.
### 5. Create a More Satisfying Revealed State
After both partners reveal answers, the app should not just feel "done." It should invite the next tiny moment of connection.
Improve:
- Add a softer post-reveal visual state
- Surface the follow-up prompt more beautifully
- Make saved reflections feel like a shared memory, not a log entry
Why this matters:
The reveal is not the end of the interaction. It is the beginning of the conversation.
### 6. Keep the Illustration System Strict
Illustrations should stay purposeful and surface-specific.
Rules:
- Do not reuse the same couple image everywhere
- Do not add characters to every screen
- Do not add large illustrations to privacy, security, account, or settings screens
- Use artwork for emotional transitions, empty states, and onboarding moments
- Keep dense task screens focused on content
Why this matters:
The app becomes more beautiful by being more intentional, not by adding more decoration.
## First Implementation Batch
Start with the smallest set of upgrades that visibly improves the app.
1. Wire the daily question artwork states on Home.
2. Add the reveal-ready illustration for `BOTH_ANSWERED`.
3. Add a restrained visual emphasis to the reveal-ready CTA.
4. Review Home in dark and light mode.
5. Capture updated README screenshots after the states are stable.
## Quality Bar
An upgrade is successful if:
- The screen feels calmer and more premium.
- The next action is clearer.
- The emotional state is obvious before reading every word.
- The app still feels private and serious.
- No screen becomes busier just because new artwork exists.

View File

@ -224,6 +224,7 @@ class MainActivity : AppCompatActivity() {
gameType = intent.getStringExtra("game_type"),
capsuleId = intent.getStringExtra("capsule_id"),
challengeId = intent.getStringExtra("challenge_id"),
dateId = intent.getStringExtra("date_id"),
avatarUrl = intent.getStringExtra("sender_avatar_url")
)
return PartnerNotificationType.fromRemoteType(type)?.routeFor(payload, coupleId)

View File

@ -33,18 +33,20 @@ class FirestoreDateReflectionDataSource @Inject constructor(
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. */
/**
* Save my reflection: encrypted content in the gated secure subdoc + a content-free metadata doc.
* Both docs are committed in a single [com.google.firebase.firestore.WriteBatch] so a mid-write
* failure can't leave an orphaned payload without its metadata (which is what gates reads/state).
*/
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(
val batch = db.batch()
batch.set(securePayloadRef(coupleId, dateId, userId), mapOf("encryptedPayload" to payload))
batch.set(
answerRef(coupleId, dateId, userId),
mapOf(
"userId" to userId,
"schemaVersion" to DateReflection.SCHEMA_VERSION,
@ -52,7 +54,26 @@ class FirestoreDateReflectionDataSource @Inject constructor(
"updatedAt" to now,
"isRevealed" to false
)
).addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
)
suspendCancellableCoroutine<Unit> { cont ->
batch.commit()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
}
/**
* Overwrite my own still-sealed reflection content (edit-before-reveal). Only the encrypted payload is
* rewritten (metadata/`createdAt` preserved). The Firestore rule permits this only while my partner
* has NOT yet reflected once they have, the content is immutable (the seal holds).
*/
suspend fun updateReflection(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)
suspendCancellableCoroutine<Unit> { cont ->
securePayloadRef(coupleId, dateId, userId).set(mapOf("encryptedPayload" to payload))
.addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
}
}
@ -95,6 +116,7 @@ class FirestoreDateReflectionDataSource @Inject constructor(
favoriteMoment = o.optString("favoriteMoment"),
surprise = o.optString("surprise"),
appreciated = o.optString("appreciated"),
notes = o.optString("notes"),
isRevealed = true,
schemaVersion = o.optInt("schemaVersion", DateReflection.SCHEMA_VERSION)
)
@ -113,6 +135,7 @@ class FirestoreDateReflectionDataSource @Inject constructor(
put("favoriteMoment", r.favoriteMoment)
put("surprise", r.surprise)
put("appreciated", r.appreciated)
put("notes", r.notes)
put("schemaVersion", DateReflection.SCHEMA_VERSION)
}.toString()
}

View File

@ -1,9 +1,10 @@
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.
* A partner's private post-date reflection (3 fixed prompts + an optional free-form note). 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 = "",
@ -11,15 +12,17 @@ data class DateReflection(
val favoriteMoment: String = "",
val surprise: String = "",
val appreciated: String = "",
val notes: String = "",
val isRevealed: Boolean = false,
val createdAt: Long = 0L,
val schemaVersion: Int = SCHEMA_VERSION
) {
val isEmpty: Boolean
get() = favoriteMoment.isBlank() && surprise.isBlank() && appreciated.isBlank()
get() = favoriteMoment.isBlank() && surprise.isBlank() && appreciated.isBlank() && notes.isBlank()
companion object {
const val SCHEMA_VERSION = 1
// v2 adds the optional free-form `notes` field (additive; older docs decode with notes = "").
const val SCHEMA_VERSION = 2
}
}

View File

@ -260,6 +260,12 @@ enum class PartnerNotificationType(
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
),
DATE_REFLECTION_OPENED(
title = "Your partner opened your reflection ✨",
body = "Open to see what you each wrote.",
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
),
// Partner-assisted restore: the partner is locked out on a new device and needs your help to
// restore their history. High-signal help request — not suppressed by routine activity toggles.
RESTORE_REQUESTED(
@ -352,7 +358,7 @@ 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 ->
DATE_REFLECTION_PARTNER, DATE_REFLECTION_READY, DATE_LOGGED, DATE_REFLECTION_OPENED ->
payload.dateId?.let { AppRoute.dateReflection(it) } ?: AppRoute.DATE_MEMORIES
RESTORE_REQUESTED -> AppRoute.RESTORE_CONSENT
// Tapping the "was this you?" alert opens account security so the owner can react.
@ -391,6 +397,7 @@ enum class PartnerNotificationType(
"thinking_of_you" -> THINKING_OF_YOU
"date_reflection_partner" -> DATE_REFLECTION_PARTNER
"date_reflection_ready" -> DATE_REFLECTION_READY
"date_reflection_opened" -> DATE_REFLECTION_OPENED
"date_logged" -> DATE_LOGGED
"restore_requested" -> RESTORE_REQUESTED
"restore_self_alert" -> RESTORE_SELF_ALERT

View File

@ -121,6 +121,9 @@ private fun routeForActivityType(type: String): String? {
"game" in t -> AppRoute.PLAY
"capsule" in t -> AppRoute.MEMORY_LANE
"challenge" in t -> AppRoute.CONNECTION_CHALLENGES
// Reflection / logged-date activity → the Replay timeline (the feed row has no dateId; the user
// taps the specific date there). Must precede the generic "date" → matches rule below.
"reflection" in t || t == "date_logged" -> AppRoute.DATE_MEMORIES
"date" in t -> AppRoute.DATE_MATCHES
"answer" in t || "reveal" in t || "question" in t || "daily" in t -> AppRoute.DAILY_QUESTION
else -> null

View File

@ -1,6 +1,7 @@
package app.closer.ui.dates
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -12,12 +13,17 @@ 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.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
@ -27,6 +33,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.R
import app.closer.core.crash.CrashReporter
import app.closer.core.navigation.AppRoute
import app.closer.data.remote.FirestoreDateMemoryDataSource
import app.closer.data.remote.FirestoreDateReflectionDataSource
@ -44,6 +51,7 @@ 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.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.text.DateFormat
@ -54,7 +62,8 @@ data class DateMemoryRow(val memory: DateMemory, val state: DateReflectionState)
data class DateMemoriesUiState(
val isLoading: Boolean = true,
val rows: List<DateMemoryRow> = emptyList()
val rows: List<DateMemoryRow> = emptyList(),
val error: String? = null
)
@HiltViewModel
@ -62,12 +71,18 @@ class DateMemoriesViewModel @Inject constructor(
private val memoryDataSource: FirestoreDateMemoryDataSource,
private val reflectionDataSource: FirestoreDateReflectionDataSource,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository
private val coupleRepository: CoupleRepository,
private val crashReporter: CrashReporter
) : ViewModel() {
private val _uiState = MutableStateFlow(DateMemoriesUiState())
val uiState: StateFlow<DateMemoriesUiState> = _uiState.asStateFlow()
private var coupleId: String? = null
// Cache reflection-state per dateId so a history re-emit only fetches state for newly-seen dates
// (avoids O(2N) reads on every tick).
private val reflectionStateCache = mutableMapOf<String, DateReflectionState>()
init {
viewModelScope.launch {
val uid = authRepository.currentUserId
@ -76,24 +91,44 @@ class DateMemoriesViewModel @Inject constructor(
_uiState.update { it.copy(isLoading = false) }
return@launch
}
coupleId = couple.id
val partnerId = couple.userIds.firstOrNull { it != uid }
memoryDataSource.observeHistory(couple.id).collect { memories ->
memoryDataSource.observeHistory(couple.id)
.catch { e ->
crashReporter.recordException(e)
_uiState.update { it.copy(isLoading = false, error = "Couldn't load your dates. Check your connection and try again.") }
}
.collect { memories ->
val rows = memories.map { m ->
val state = reflectionStateCache[m.id] ?: run {
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 {
val s = when {
mine && partner -> DateReflectionState.BOTH_DONE
mine -> DateReflectionState.AWAITING_PARTNER
else -> DateReflectionState.NONE
}
reflectionStateCache[m.id] = s
s
}
DateMemoryRow(m, state)
}
_uiState.update { it.copy(isLoading = false, rows = rows) }
_uiState.update { it.copy(isLoading = false, error = null, rows = rows) }
}
}
}
fun deleteMemory(id: String) {
val cid = coupleId ?: return
reflectionStateCache.remove(id)
viewModelScope.launch {
runCatching { memoryDataSource.delete(cid, id) }
.onFailure { crashReporter.recordException(it) }
// observeHistory re-emits without the row; no manual state edit needed.
}
}
}
@Composable
@ -102,25 +137,57 @@ fun DateMemoriesScreen(
viewModel: DateMemoriesViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
var pendingDelete by remember { mutableStateOf<DateMemory?>(null) }
SettingsSubpage(title = "Date memories", onBack = { onNavigate("back") }) { padding ->
when {
state.isLoading -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CloserHeartLoader() }
state.error != null -> Box(Modifier.fillMaxSize().padding(padding).padding(horizontal = 24.dp), Alignment.Center) {
Text(
state.error!!,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
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)) }
}
DateMemoryCard(
row = row,
onClick = { onNavigate(AppRoute.dateReflection(row.memory.id)) },
onLongClick = { pendingDelete = row.memory }
)
}
}
}
}
pendingDelete?.let { memory ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Remove this date?") },
text = { Text("This removes \"${memory.title.ifBlank { "this date" }}\" from your timeline. Your reflections won't be shown.") },
confirmButton = {
TextButton(onClick = { viewModel.deleteMemory(memory.id); pendingDelete = null }) {
Text("Remove", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { pendingDelete = null }) { Text("Cancel") } }
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DateMemoryCard(row: DateMemoryRow, onClick: () -> Unit) {
CloserCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), containerColor = closerCardColor()) {
private fun DateMemoryCard(row: DateMemoryRow, onClick: () -> Unit, onLongClick: () -> Unit) {
CloserCard(
modifier = Modifier.fillMaxWidth().combinedClickable(onClick = onClick, onLongClick = onLongClick),
containerColor = closerCardColor()
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),

View File

@ -7,12 +7,14 @@ 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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
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.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -21,12 +23,14 @@ 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.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.crash.CrashReporter
import app.closer.data.remote.FirestoreDateMemoryDataSource
import app.closer.data.remote.FirestoreDateReflectionDataSource
import app.closer.domain.model.DateReflection
import app.closer.domain.repository.AuthRepository
@ -44,30 +48,39 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/** The three fixed post-date reflection prompts. */
/** The four post-date reflection prompts (the last is an open-ended, optional note). */
private val REFLECTION_PROMPTS = listOf(
"Your favorite moment",
"What surprised you",
"What you appreciated most"
"What you appreciated most",
"Anything else worth remembering"
)
enum class ReflectionPhase { LOADING, EDIT, AWAITING_PARTNER, REVEALED, ERROR }
/** Max characters per reflection field — keeps the encrypted payload bounded. */
private const val MAX_PROMPT_LEN = 500
private const val MAX_NOTES_LEN = 1000
enum class ReflectionPhase { LOADING, EDIT, AWAITING_PARTNER, REVEALED, LOCKED, ERROR }
data class DateReflectionUiState(
val phase: ReflectionPhase = ReflectionPhase.LOADING,
val dateTitle: String? = null,
val partnerName: String? = null,
val favoriteMoment: String = "",
val surprise: String = "",
val appreciated: String = "",
val notes: String = "",
val mine: DateReflection? = null,
val partner: DateReflection? = null,
val isSaving: Boolean = false,
val isEditing: Boolean = false,
val error: String? = null
)
@HiltViewModel
class DateReflectionViewModel @Inject constructor(
private val reflectionDataSource: FirestoreDateReflectionDataSource,
private val memoryDataSource: FirestoreDateMemoryDataSource,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository,
@ -81,10 +94,16 @@ class DateReflectionViewModel @Inject constructor(
private var coupleId: String? = null
private var partnerId: String? = null
private var revealedMarked = false
init { load() }
private fun load() {
// Defensive: a missing/malformed nav arg would otherwise read/write an empty path segment.
if (dateId.isBlank()) {
_uiState.update { it.copy(phase = ReflectionPhase.ERROR, error = "This date couldn't be opened.") }
return
}
viewModelScope.launch {
val uid = authRepository.currentUserId
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
@ -97,7 +116,10 @@ class DateReflectionViewModel @Inject constructor(
val partnerName = partnerId?.let {
runCatching { userRepository.getUser(it)?.displayName }.getOrNull()
}?.takeIf { it.isNotBlank() } ?: "your partner"
_uiState.update { it.copy(partnerName = partnerName) }
val dateTitle = runCatching {
memoryDataSource.getHistoryOnce(couple.id).firstOrNull { it.id == dateId }?.title
}.getOrNull()?.takeIf { it.isNotBlank() }
_uiState.update { it.copy(partnerName = partnerName, dateTitle = dateTitle) }
refresh()
// Live-complete the reveal the moment the partner reflects.
partnerId?.let { pid ->
@ -129,15 +151,41 @@ class DateReflectionViewModel @Inject constructor(
val partner = pid?.let {
runCatching { reflectionDataSource.decryptReflectionFor(cid, dateId, it) }.getOrNull()
}
// Both reflected but we can't decrypt (couple key unavailable on this device, e.g. a
// freshly-restored device before unlock) → show a LOCKED state, not blank dashes.
if (mine == null) {
_uiState.update { it.copy(phase = ReflectionPhase.LOCKED) }
return
}
if (!revealedMarked) {
revealedMarked = true
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 onFavoriteMoment(v: String) = _uiState.update { it.copy(favoriteMoment = v.take(MAX_PROMPT_LEN), error = null) }
fun onSurprise(v: String) = _uiState.update { it.copy(surprise = v.take(MAX_PROMPT_LEN), error = null) }
fun onAppreciated(v: String) = _uiState.update { it.copy(appreciated = v.take(MAX_PROMPT_LEN), error = null) }
fun onNotes(v: String) = _uiState.update { it.copy(notes = v.take(MAX_NOTES_LEN), error = null) }
/** Re-open the editor pre-filled to edit a still-sealed reflection (before the partner reflects). */
fun startEdit() {
val mine = _uiState.value.mine ?: return
_uiState.update {
it.copy(
phase = ReflectionPhase.EDIT,
isEditing = true,
favoriteMoment = mine.favoriteMoment,
surprise = mine.surprise,
appreciated = mine.appreciated,
notes = mine.notes,
error = null
)
}
}
fun save() {
val state = _uiState.value
@ -145,28 +193,31 @@ class DateReflectionViewModel @Inject constructor(
val cid = coupleId
val uid = authRepository.currentUserId
if (cid == null || uid == null) return
if (state.favoriteMoment.isBlank() && state.surprise.isBlank() && state.appreciated.isBlank()) {
if (state.favoriteMoment.isBlank() && state.surprise.isBlank() &&
state.appreciated.isBlank() && state.notes.isBlank()
) {
_uiState.update { it.copy(error = "Add at least one reflection first.") }
return
}
_uiState.update { it.copy(isSaving = true, error = null) }
val editing = state.isEditing
viewModelScope.launch {
runCatching {
reflectionDataSource.saveReflection(
cid, dateId, uid,
DateReflection(
val reflection = DateReflection(
dateId = dateId, userId = uid,
favoriteMoment = state.favoriteMoment.trim(),
surprise = state.surprise.trim(),
appreciated = state.appreciated.trim()
)
appreciated = state.appreciated.trim(),
notes = state.notes.trim()
)
runCatching {
if (editing) reflectionDataSource.updateReflection(cid, dateId, uid, reflection)
else reflectionDataSource.saveReflection(cid, dateId, uid, reflection)
}.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) }
_uiState.update { it.copy(isSaving = false, isEditing = false) }
refresh()
}
}
@ -183,20 +234,32 @@ fun DateReflectionScreen(
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)
}
ReflectionPhase.ERROR -> CenteredMessage(padding, state.error ?: "Something went wrong.")
ReflectionPhase.LOCKED -> CenteredMessage(
padding,
"Locked — unlock your vault on this device to view these reflections."
)
else -> Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
state.dateTitle?.let {
Text(
it,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
}
when (state.phase) {
ReflectionPhase.EDIT -> ReflectionEditor(state, viewModel)
ReflectionPhase.AWAITING_PARTNER -> AwaitingPartner(state)
ReflectionPhase.AWAITING_PARTNER -> AwaitingPartner(state, viewModel)
ReflectionPhase.REVEALED -> RevealedReflections(state)
else -> {}
}
@ -207,32 +270,54 @@ fun DateReflectionScreen(
}
@Composable
private fun ReflectionEditor(state: DateReflectionUiState, viewModel: DateReflectionViewModel) {
private fun CenteredMessage(padding: androidx.compose.foundation.layout.PaddingValues, message: String) {
Box(Modifier.fillMaxSize().padding(padding).padding(horizontal = 24.dp), Alignment.Center) {
Text(
"Reflect privately — your words stay sealed until you've both shared.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
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) {
private fun ReflectionEditor(state: DateReflectionUiState, viewModel: DateReflectionViewModel) {
Text(
"Reflect privately — once you save, it's sealed until you both reveal together.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ReflectionField(state.favoriteMoment, viewModel::onFavoriteMoment, REFLECTION_PROMPTS[0], MAX_PROMPT_LEN, minLines = 2)
ReflectionField(state.surprise, viewModel::onSurprise, REFLECTION_PROMPTS[1], MAX_PROMPT_LEN, minLines = 2)
ReflectionField(state.appreciated, viewModel::onAppreciated, REFLECTION_PROMPTS[2], MAX_PROMPT_LEN, minLines = 2)
ReflectionField(state.notes, viewModel::onNotes, REFLECTION_PROMPTS[3], MAX_NOTES_LEN, minLines = 3)
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 if (state.isEditing) "Update my reflection" else "Save my reflection")
}
}
@Composable
private fun ReflectionField(
value: String,
onValueChange: (String) -> Unit,
label: String,
maxLen: Int,
minLines: Int
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
minLines = minLines,
supportingText = { Text("${value.length}/$maxLen") }
)
}
@Composable
private fun AwaitingPartner(state: DateReflectionUiState, viewModel: DateReflectionViewModel) {
Text(
"Saved privately 💜 — waiting for ${state.partnerName} to reflect. You'll reveal together.",
style = MaterialTheme.typography.bodyLarge,
@ -240,6 +325,9 @@ private fun AwaitingPartner(state: DateReflectionUiState) {
color = MaterialTheme.colorScheme.onSurface
)
state.mine?.let { ReflectionCard(title = "Your reflection", reflection = it) }
OutlinedButton(onClick = viewModel::startEdit, modifier = Modifier.fillMaxWidth()) {
Text("Edit my reflection")
}
}
@Composable
@ -282,5 +370,6 @@ private fun ReflectionCard(title: String, reflection: DateReflection) {
private fun promptValue(r: DateReflection?, index: Int): String = when (index) {
0 -> r?.favoriteMoment.orEmpty()
1 -> r?.surprise.orEmpty()
else -> r?.appreciated.orEmpty()
2 -> r?.appreciated.orEmpty()
else -> r?.notes.orEmpty()
}

View File

@ -388,14 +388,14 @@ 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.
// Pending date reflection: true if ANY recent completed date still lacks this
// user's reflection (not just the latest), so an older un-reflected date keeps
// nudging too. Bounded to the most recent dates to cap the reads.
val reflectionJob = async {
runCatching {
val latest = dateMemoryDataSource.getHistoryOnce(coupleId).firstOrNull()
latest != null &&
!dateReflectionDataSource.hasReflected(coupleId, latest.id, uid)
dateMemoryDataSource.getHistoryOnce(coupleId)
.take(RECENT_DATES_FOR_REFLECTION_NUDGE)
.any { !dateReflectionDataSource.hasReflected(coupleId, it.id, uid) }
}.getOrDefault(false)
}
val (waitingSession, waitingRoute) = gameJob.await()
@ -970,5 +970,7 @@ class HomeViewModel @Inject constructor(
companion object {
private const val TAG = "HomeViewModel"
private val STREAK_MILESTONES = listOf(7, 30, 100, 365)
// How many recent completed dates the reflection nudge scans for an un-reflected one.
private const val RECENT_DATES_FOR_REFLECTION_NUDGE = 10
}
}

View File

@ -18,6 +18,19 @@ service cloud.firestore {
get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds;
}
// The other member of a 2-person couple (relative to uid).
function otherCoupleMember(coupleId, uid) {
return get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds[0] == uid
? get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds[1]
: get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds[0];
}
// Has the partner already written their reflection for this date? Once true, the author's
// reflection is sealed (edits are no longer allowed).
function partnerReflectedDate(coupleId, dateId, uid) {
return exists(/databases/$(database)/documents/couples/$(coupleId)/date_reflections/$(dateId)/answers/$(otherCoupleMember(coupleId, uid)));
}
function isValidInviteCode(code) {
// Code must be exactly 6 alphanumeric characters
return code.matches('^[a-zA-Z0-9]{6}$');
@ -685,7 +698,14 @@ service cloud.firestore {
&& request.auth.uid == userId
&& isCiphertext(request.resource.data.encryptedPayload)
&& request.resource.data.keys().hasOnly(['encryptedPayload']);
allow update, delete: if false;
// Author may edit their OWN still-sealed reflection ONLY until the partner reflects. Once the
// partner has reflected the content is immutable (the "sealed until both reveal" guarantee).
allow update: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& !partnerReflectedDate(coupleId, dateId, userId)
&& isCiphertext(request.resource.data.encryptedPayload)
&& request.resource.data.keys().hasOnly(['encryptedPayload']);
allow delete: if false;
}
}

View File

@ -0,0 +1,111 @@
"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.onDateReflectionRevealed = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
const quietHours_1 = require("../notifications/quietHours");
const pruneTokens_1 = require("../notifications/pruneTokens");
/**
* Fires when a partner OPENS (reveals) the shared date reflections their own reflection metadata doc
* flips `isRevealed` false true (`couples/{coupleId}/date_reflections/{dateId}/answers/{userId}`).
* Notifies the OTHER partner that their reflection was read, mirroring daily's `onAnswerRevealed`:
* 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.onDateReflectionRevealed = functions.firestore
.document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}')
.onUpdate(async (change, context) => {
var _a, _b, _c, _d, _e;
const { coupleId, dateId, userId } = context.params;
const before = change.before.data();
const after = change.after.data();
// Only on the false → true reveal transition.
if (before.isRevealed === true || after.isRevealed !== true)
return;
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();
if (((_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.notifPartnerAnswered) === false) {
console.log(`[onDateReflectionRevealed] ${partnerId} has partner notifications off`);
return;
}
const title = 'Your partner opened your reflection ✨';
const body = 'Open to see what you each wrote.';
const type = 'date_reflection_opened';
// 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(),
});
if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) {
console.log(`[onDateReflectionRevealed] ${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(`[onDateReflectionRevealed] FCM failed for ${tokens[i]}:`, r.reason);
});
await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, results);
});
//# sourceMappingURL=onDateReflectionRevealed.js.map

View File

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

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.onRestoreFulfilled = exports.onRestoreRequested = 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;
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.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = 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
@ -69,6 +69,8 @@ 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 onDateReflectionRevealed_1 = require("./dates/onDateReflectionRevealed");
Object.defineProperty(exports, "onDateReflectionRevealed", { enumerable: true, get: function () { return onDateReflectionRevealed_1.onDateReflectionRevealed; } });
var onDateHistoryCreated_1 = require("./dates/onDateHistoryCreated");
Object.defineProperty(exports, "onDateHistoryCreated", { enumerable: true, get: function () { return onDateHistoryCreated_1.onDateHistoryCreated; } });
var onRestoreRequested_1 = require("./backup/onRestoreRequested");

View File

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

View File

@ -0,0 +1,92 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { recipientInQuietHours } from '../notifications/quietHours'
import { pruneDeadTokens } from '../notifications/pruneTokens'
/**
* Fires when a partner OPENS (reveals) the shared date reflections their own reflection metadata doc
* flips `isRevealed` false true (`couples/{coupleId}/date_reflections/{dateId}/answers/{userId}`).
* Notifies the OTHER partner that their reflection was read, mirroring daily's `onAnswerRevealed`:
* 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 onDateReflectionRevealed = functions.firestore
.document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}')
.onUpdate(async (change, context) => {
const { coupleId, dateId, userId } = context.params as {
coupleId: string
dateId: string
userId: string
}
const before = change.before.data() as Partial<Record<string, unknown>>
const after = change.after.data() as Partial<Record<string, unknown>>
// Only on the false → true reveal transition.
if (before.isRevealed === true || after.isRevealed !== true) return
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()
if (partnerUserDoc.data()?.notifPartnerAnswered === false) {
console.log(`[onDateReflectionRevealed] ${partnerId} has partner notifications off`)
return
}
const title = 'Your partner opened your reflection ✨'
const body = 'Open to see what you each wrote.'
const type = 'date_reflection_opened'
// 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(),
})
if (recipientInQuietHours(partnerUserDoc.data())) {
console.log(`[onDateReflectionRevealed] ${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' } },
} as admin.messaging.Message)
)
)
results.forEach((r, i) => {
if (r.status === 'rejected') console.warn(`[onDateReflectionRevealed] FCM failed for ${tokens[i]}:`, r.reason)
})
await pruneDeadTokens(db, partnerId, tokens, results)
})

View File

@ -26,6 +26,7 @@ export { sendReengagementReminder } from './notifications/reengagement'
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
export { notifyOnDateMatch } from './dates/createDateMatch'
export { onDateReflectionWritten } from './dates/onDateReflectionWritten'
export { onDateReflectionRevealed } from './dates/onDateReflectionRevealed'
export { onDateHistoryCreated } from './dates/onDateHistoryCreated'
export { onRestoreRequested, onRestoreFulfilled } from './backup/onRestoreRequested'
export {

62
scripts/ime-scan.sh Executable file
View File

@ -0,0 +1,62 @@
#!/bin/bash
#
# CloserApp — IME/keyboard-handling scanner (Pass J pre-check)
#
# WHY: the app is edge-to-edge (WindowCompat, decorFitsSystemWindows=false) with
# windowSoftInputMode=adjustResize. Under edge-to-edge the window does NOT physically resize, so any
# screen with a text field must reserve keyboard space via WindowInsets — `imePadding()` or
# `safeDrawingPadding()` — or the soft keyboard overlays the fields and they can't be typed into.
# This scanner caught DateReflectionScreen shipping without it ("you can't type in Date Reflection").
#
# It flags every file containing a text field (OutlinedTextField / BasicTextField / TextField) that does
# NOT itself call imePadding()/safeDrawingPadding(). Reusable *components* that are always hosted inside an
# IME-handling screen are allowlisted below (verify the host still handles IME before adding one).
#
# ⛔ CLAUDE: keep this runnable from the project root; update the allowlist ONLY after confirming the
# component's host screens handle IME. Exit code is non-zero if any non-allowlisted file is missing.
#
# Usage: ./scripts/ime-scan.sh
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
UI_DIR="$PROJECT_ROOT/app/src/main/java/app/closer/ui"
# Components whose IME handling is provided by their host screen (verified). Basenames only.
ALLOWLIST=(
"QuestionAnswerInput.kt" # hosted by QuestionThreadScreen (imePadding) + LocalQuestionContent (safeDrawingPadding)
"QuestionDiscussionThread.kt" # hosted by QuestionThreadScreen (imePadding)
)
is_allowlisted() {
local base="$1"
for a in "${ALLOWLIST[@]}"; do [[ "$base" == "$a" ]] && return 0; done
return 1
}
fail=0
printf '=== IME/keyboard-handling scan (text-input screens) ===\n\n'
# Files with any text-input composable.
mapfile -t files < <(grep -rlE "OutlinedTextField\(|BasicTextField\(| TextField\(" "$UI_DIR" 2>/dev/null | sort)
for f in "${files[@]}"; do
base="$(basename "$f")"
rel="${f#"$PROJECT_ROOT"/}"
if grep -qE "imePadding|safeDrawingPadding" "$f"; then
printf ' OK %s\n' "$rel"
elif is_allowlisted "$base"; then
printf ' OK(host) %s (component; host handles IME)\n' "$rel"
else
printf ' ** MISSING IME ** %s\n' "$rel"
fail=1
fi
done
echo
if [[ "$fail" -ne 0 ]]; then
echo "FAIL: one or more text-input screens lack imePadding()/safeDrawingPadding()."
echo "Add IME handling to the scrollable content Column (see e.g. ConversationScreen.kt), or allowlist"
echo "the file here ONLY if it is a component whose host screen already handles IME."
exit 1
fi
echo "PASS: every text-input screen handles the keyboard."

File diff suppressed because it is too large Load Diff