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 ✅ | | 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 | | 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 | | 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)** | | 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 | | 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. remote config, and any debug-only screens that should not ship.
- **Backend/rules:** inspect Firestore rules, indexes/queries, Functions triggers/callables, Storage paths, scheduled - **Backend/rules:** inspect Firestore rules, indexes/queries, Functions triggers/callables, Storage paths, scheduled
jobs, and migrations for new data shapes or access paths. 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 - **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. 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 - **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 + - **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:`), `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**. 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 - **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 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 + 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) · in-app/Together record**; tapping the push → Home, not a dead-end) ·
`date_reflection_partner`/`date_reflection_ready`(**onDateReflectionWritten** → partner → the date reflection; `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) · "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_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 `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) · suppressed by the routine partner-activity toggle, only quiet hours) · `spki`(key identity/confirm → security/key screen) ·
`subscription_entitlement_changed` & `security_recovery` (if present). `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). 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 - **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** **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 - **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 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). 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 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. (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. 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: - **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 **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. 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 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). [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 - **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 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`) **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"), gameType = intent.getStringExtra("game_type"),
capsuleId = intent.getStringExtra("capsule_id"), capsuleId = intent.getStringExtra("capsule_id"),
challengeId = intent.getStringExtra("challenge_id"), challengeId = intent.getStringExtra("challenge_id"),
dateId = intent.getStringExtra("date_id"),
avatarUrl = intent.getStringExtra("sender_avatar_url") avatarUrl = intent.getStringExtra("sender_avatar_url")
) )
return PartnerNotificationType.fromRemoteType(type)?.routeFor(payload, coupleId) 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) = private fun securePayloadRef(coupleId: String, dateId: String, userId: String) =
answerRef(coupleId, dateId, userId).collection("secure").document("payload") 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) { suspend fun saveReflection(coupleId: String, dateId: String, userId: String, reflection: DateReflection) {
val aead = encryptionManager.aeadFor(coupleId) val aead = encryptionManager.aeadFor(coupleId)
?: throw IllegalStateException("Couple key unavailable for $coupleId") ?: throw IllegalStateException("Couple key unavailable for $coupleId")
val payload = fieldEncryptor.encrypt(encode(reflection), aead, coupleId) val payload = fieldEncryptor.encrypt(encode(reflection), aead, coupleId)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
suspendCancellableCoroutine<Unit> { cont -> val batch = db.batch()
securePayloadRef(coupleId, dateId, userId).set(mapOf("encryptedPayload" to payload)) batch.set(securePayloadRef(coupleId, dateId, userId), mapOf("encryptedPayload" to payload))
.addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) } batch.set(
} answerRef(coupleId, dateId, userId),
suspendCancellableCoroutine<Unit> { cont ->
answerRef(coupleId, dateId, userId).set(
mapOf( mapOf(
"userId" to userId, "userId" to userId,
"schemaVersion" to DateReflection.SCHEMA_VERSION, "schemaVersion" to DateReflection.SCHEMA_VERSION,
@ -52,7 +54,26 @@ class FirestoreDateReflectionDataSource @Inject constructor(
"updatedAt" to now, "updatedAt" to now,
"isRevealed" to false "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"), favoriteMoment = o.optString("favoriteMoment"),
surprise = o.optString("surprise"), surprise = o.optString("surprise"),
appreciated = o.optString("appreciated"), appreciated = o.optString("appreciated"),
notes = o.optString("notes"),
isRevealed = true, isRevealed = true,
schemaVersion = o.optInt("schemaVersion", DateReflection.SCHEMA_VERSION) schemaVersion = o.optInt("schemaVersion", DateReflection.SCHEMA_VERSION)
) )
@ -113,6 +135,7 @@ class FirestoreDateReflectionDataSource @Inject constructor(
put("favoriteMoment", r.favoriteMoment) put("favoriteMoment", r.favoriteMoment)
put("surprise", r.surprise) put("surprise", r.surprise)
put("appreciated", r.appreciated) put("appreciated", r.appreciated)
put("notes", r.notes)
put("schemaVersion", DateReflection.SCHEMA_VERSION) put("schemaVersion", DateReflection.SCHEMA_VERSION)
}.toString() }.toString()
} }

View File

@ -1,9 +1,10 @@
package app.closer.domain.model package app.closer.domain.model
/** /**
* A partner's private post-date reflection (3 prompts). The content is couple-key encrypted and lives in * A partner's private post-date reflection (3 fixed prompts + an optional free-form note). The content is
* a read-gated `secure/payload` subdoc neither partner can read the other's until BOTH have reflected * couple-key encrypted and lives in a read-gated `secure/payload` subdoc neither partner can read the
* (enforced by the Firestore rule, mirroring the daily-question reveal). Privacy-native by design. * other's until BOTH have reflected (enforced by the Firestore rule, mirroring the daily-question reveal).
* Privacy-native by design.
*/ */
data class DateReflection( data class DateReflection(
val dateId: String = "", val dateId: String = "",
@ -11,15 +12,17 @@ data class DateReflection(
val favoriteMoment: String = "", val favoriteMoment: String = "",
val surprise: String = "", val surprise: String = "",
val appreciated: String = "", val appreciated: String = "",
val notes: String = "",
val isRevealed: Boolean = false, val isRevealed: Boolean = false,
val createdAt: Long = 0L, val createdAt: Long = 0L,
val schemaVersion: Int = SCHEMA_VERSION val schemaVersion: Int = SCHEMA_VERSION
) { ) {
val isEmpty: Boolean val isEmpty: Boolean
get() = favoriteMoment.isBlank() && surprise.isBlank() && appreciated.isBlank() get() = favoriteMoment.isBlank() && surprise.isBlank() && appreciated.isBlank() && notes.isBlank()
companion object { 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, channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER 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 // 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 their history. High-signal help request — not suppressed by routine activity toggles.
RESTORE_REQUESTED( RESTORE_REQUESTED(
@ -352,7 +358,7 @@ enum class PartnerNotificationType(
CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE
GENTLE_REMINDER -> AppRoute.DAILY_QUESTION GENTLE_REMINDER -> AppRoute.DAILY_QUESTION
THINKING_OF_YOU -> AppRoute.HOME 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 payload.dateId?.let { AppRoute.dateReflection(it) } ?: AppRoute.DATE_MEMORIES
RESTORE_REQUESTED -> AppRoute.RESTORE_CONSENT RESTORE_REQUESTED -> AppRoute.RESTORE_CONSENT
// Tapping the "was this you?" alert opens account security so the owner can react. // 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 "thinking_of_you" -> THINKING_OF_YOU
"date_reflection_partner" -> DATE_REFLECTION_PARTNER "date_reflection_partner" -> DATE_REFLECTION_PARTNER
"date_reflection_ready" -> DATE_REFLECTION_READY "date_reflection_ready" -> DATE_REFLECTION_READY
"date_reflection_opened" -> DATE_REFLECTION_OPENED
"date_logged" -> DATE_LOGGED "date_logged" -> DATE_LOGGED
"restore_requested" -> RESTORE_REQUESTED "restore_requested" -> RESTORE_REQUESTED
"restore_self_alert" -> RESTORE_SELF_ALERT "restore_self_alert" -> RESTORE_SELF_ALERT

View File

@ -121,6 +121,9 @@ private fun routeForActivityType(type: String): String? {
"game" in t -> AppRoute.PLAY "game" in t -> AppRoute.PLAY
"capsule" in t -> AppRoute.MEMORY_LANE "capsule" in t -> AppRoute.MEMORY_LANE
"challenge" in t -> AppRoute.CONNECTION_CHALLENGES "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 "date" in t -> AppRoute.DATE_MATCHES
"answer" in t || "reveal" in t || "question" in t || "daily" in t -> AppRoute.DAILY_QUESTION "answer" in t || "reveal" in t || "question" in t || "daily" in t -> AppRoute.DAILY_QUESTION
else -> null else -> null

View File

@ -1,6 +1,7 @@
package app.closer.ui.dates 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -27,6 +33,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.R import app.closer.R
import app.closer.core.crash.CrashReporter
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.data.remote.FirestoreDateMemoryDataSource import app.closer.data.remote.FirestoreDateMemoryDataSource
import app.closer.data.remote.FirestoreDateReflectionDataSource 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.DateFormat import java.text.DateFormat
@ -54,7 +62,8 @@ data class DateMemoryRow(val memory: DateMemory, val state: DateReflectionState)
data class DateMemoriesUiState( data class DateMemoriesUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val rows: List<DateMemoryRow> = emptyList() val rows: List<DateMemoryRow> = emptyList(),
val error: String? = null
) )
@HiltViewModel @HiltViewModel
@ -62,12 +71,18 @@ class DateMemoriesViewModel @Inject constructor(
private val memoryDataSource: FirestoreDateMemoryDataSource, private val memoryDataSource: FirestoreDateMemoryDataSource,
private val reflectionDataSource: FirestoreDateReflectionDataSource, private val reflectionDataSource: FirestoreDateReflectionDataSource,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository private val coupleRepository: CoupleRepository,
private val crashReporter: CrashReporter
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(DateMemoriesUiState()) private val _uiState = MutableStateFlow(DateMemoriesUiState())
val uiState: StateFlow<DateMemoriesUiState> = _uiState.asStateFlow() 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 { init {
viewModelScope.launch { viewModelScope.launch {
val uid = authRepository.currentUserId val uid = authRepository.currentUserId
@ -76,24 +91,44 @@ class DateMemoriesViewModel @Inject constructor(
_uiState.update { it.copy(isLoading = false) } _uiState.update { it.copy(isLoading = false) }
return@launch return@launch
} }
coupleId = couple.id
val partnerId = couple.userIds.firstOrNull { it != uid } 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 rows = memories.map { m ->
val state = reflectionStateCache[m.id] ?: run {
val mine = runCatching { reflectionDataSource.hasReflected(couple.id, m.id, uid) }.getOrDefault(false) val mine = runCatching { reflectionDataSource.hasReflected(couple.id, m.id, uid) }.getOrDefault(false)
val partner = partnerId?.let { val partner = partnerId?.let {
runCatching { reflectionDataSource.hasReflected(couple.id, m.id, it) }.getOrDefault(false) runCatching { reflectionDataSource.hasReflected(couple.id, m.id, it) }.getOrDefault(false)
} ?: false } ?: false
val state = when { val s = when {
mine && partner -> DateReflectionState.BOTH_DONE mine && partner -> DateReflectionState.BOTH_DONE
mine -> DateReflectionState.AWAITING_PARTNER mine -> DateReflectionState.AWAITING_PARTNER
else -> DateReflectionState.NONE else -> DateReflectionState.NONE
} }
reflectionStateCache[m.id] = s
s
}
DateMemoryRow(m, state) 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 @Composable
@ -102,25 +137,57 @@ fun DateMemoriesScreen(
viewModel: DateMemoriesViewModel = hiltViewModel() viewModel: DateMemoriesViewModel = hiltViewModel()
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
var pendingDelete by remember { mutableStateOf<DateMemory?>(null) }
SettingsSubpage(title = "Date memories", onBack = { onNavigate("back") }) { padding -> SettingsSubpage(title = "Date memories", onBack = { onNavigate("back") }) { padding ->
when { when {
state.isLoading -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CloserHeartLoader() } 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)) state.rows.isEmpty() -> DateMemoriesEmpty(Modifier.padding(padding))
else -> LazyColumn( else -> LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 20.dp), modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(state.rows, key = { it.memory.id }) { row -> 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 @Composable
private fun DateMemoryCard(row: DateMemoryRow, onClick: () -> Unit) { private fun DateMemoryCard(row: DateMemoryRow, onClick: () -> Unit, onLongClick: () -> Unit) {
CloserCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), containerColor = closerCardColor()) { CloserCard(
modifier = Modifier.fillMaxWidth().combinedClickable(onClick = onClick, onLongClick = onLongClick),
containerColor = closerCardColor()
) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -21,12 +23,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.crash.CrashReporter import app.closer.core.crash.CrashReporter
import app.closer.data.remote.FirestoreDateMemoryDataSource
import app.closer.data.remote.FirestoreDateReflectionDataSource import app.closer.data.remote.FirestoreDateReflectionDataSource
import app.closer.domain.model.DateReflection import app.closer.domain.model.DateReflection
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
@ -44,30 +48,39 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject 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( private val REFLECTION_PROMPTS = listOf(
"Your favorite moment", "Your favorite moment",
"What surprised you", "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( data class DateReflectionUiState(
val phase: ReflectionPhase = ReflectionPhase.LOADING, val phase: ReflectionPhase = ReflectionPhase.LOADING,
val dateTitle: String? = null,
val partnerName: String? = null, val partnerName: String? = null,
val favoriteMoment: String = "", val favoriteMoment: String = "",
val surprise: String = "", val surprise: String = "",
val appreciated: String = "", val appreciated: String = "",
val notes: String = "",
val mine: DateReflection? = null, val mine: DateReflection? = null,
val partner: DateReflection? = null, val partner: DateReflection? = null,
val isSaving: Boolean = false, val isSaving: Boolean = false,
val isEditing: Boolean = false,
val error: String? = null val error: String? = null
) )
@HiltViewModel @HiltViewModel
class DateReflectionViewModel @Inject constructor( class DateReflectionViewModel @Inject constructor(
private val reflectionDataSource: FirestoreDateReflectionDataSource, private val reflectionDataSource: FirestoreDateReflectionDataSource,
private val memoryDataSource: FirestoreDateMemoryDataSource,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
@ -81,10 +94,16 @@ class DateReflectionViewModel @Inject constructor(
private var coupleId: String? = null private var coupleId: String? = null
private var partnerId: String? = null private var partnerId: String? = null
private var revealedMarked = false
init { load() } init { load() }
private fun 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 { viewModelScope.launch {
val uid = authRepository.currentUserId val uid = authRepository.currentUserId
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() } val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
@ -97,7 +116,10 @@ class DateReflectionViewModel @Inject constructor(
val partnerName = partnerId?.let { val partnerName = partnerId?.let {
runCatching { userRepository.getUser(it)?.displayName }.getOrNull() runCatching { userRepository.getUser(it)?.displayName }.getOrNull()
}?.takeIf { it.isNotBlank() } ?: "your partner" }?.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() refresh()
// Live-complete the reveal the moment the partner reflects. // Live-complete the reveal the moment the partner reflects.
partnerId?.let { pid -> partnerId?.let { pid ->
@ -129,15 +151,41 @@ class DateReflectionViewModel @Inject constructor(
val partner = pid?.let { val partner = pid?.let {
runCatching { reflectionDataSource.decryptReflectionFor(cid, dateId, it) }.getOrNull() 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) } runCatching { reflectionDataSource.markRevealed(cid, dateId, uid) }
}
_uiState.update { it.copy(phase = ReflectionPhase.REVEALED, mine = mine, partner = partner) } _uiState.update { it.copy(phase = ReflectionPhase.REVEALED, mine = mine, partner = partner) }
} }
} }
} }
fun onFavoriteMoment(v: String) = _uiState.update { it.copy(favoriteMoment = 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) } 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) } 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() { fun save() {
val state = _uiState.value val state = _uiState.value
@ -145,28 +193,31 @@ class DateReflectionViewModel @Inject constructor(
val cid = coupleId val cid = coupleId
val uid = authRepository.currentUserId val uid = authRepository.currentUserId
if (cid == null || uid == null) return 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.") } _uiState.update { it.copy(error = "Add at least one reflection first.") }
return return
} }
_uiState.update { it.copy(isSaving = true, error = null) } _uiState.update { it.copy(isSaving = true, error = null) }
val editing = state.isEditing
viewModelScope.launch { viewModelScope.launch {
runCatching { val reflection = DateReflection(
reflectionDataSource.saveReflection(
cid, dateId, uid,
DateReflection(
dateId = dateId, userId = uid, dateId = dateId, userId = uid,
favoriteMoment = state.favoriteMoment.trim(), favoriteMoment = state.favoriteMoment.trim(),
surprise = state.surprise.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 { }.onFailure {
crashReporter.recordException(it) crashReporter.recordException(it)
_uiState.update { s -> s.copy(isSaving = false, error = "Couldn't save. Try again.") } _uiState.update { s -> s.copy(isSaving = false, error = "Couldn't save. Try again.") }
return@launch return@launch
} }
_uiState.update { it.copy(isSaving = false) } _uiState.update { it.copy(isSaving = false, isEditing = false) }
refresh() refresh()
} }
} }
@ -183,20 +234,32 @@ fun DateReflectionScreen(
SettingsSubpage(title = "Date reflection", onBack = { onNavigate("back") }) { padding -> SettingsSubpage(title = "Date reflection", onBack = { onNavigate("back") }) { padding ->
when (state.phase) { when (state.phase) {
ReflectionPhase.LOADING -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CloserHeartLoader() } ReflectionPhase.LOADING -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CloserHeartLoader() }
ReflectionPhase.ERROR -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { ReflectionPhase.ERROR -> CenteredMessage(padding, state.error ?: "Something went wrong.")
Text(state.error ?: "Something went wrong.", color = MaterialTheme.colorScheme.onSurfaceVariant) ReflectionPhase.LOCKED -> CenteredMessage(
} padding,
"Locked — unlock your vault on this device to view these reflections."
)
else -> Column( else -> Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.imePadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.dp), .padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(14.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) { when (state.phase) {
ReflectionPhase.EDIT -> ReflectionEditor(state, viewModel) ReflectionPhase.EDIT -> ReflectionEditor(state, viewModel)
ReflectionPhase.AWAITING_PARTNER -> AwaitingPartner(state) ReflectionPhase.AWAITING_PARTNER -> AwaitingPartner(state, viewModel)
ReflectionPhase.REVEALED -> RevealedReflections(state) ReflectionPhase.REVEALED -> RevealedReflections(state)
else -> {} else -> {}
} }
@ -207,32 +270,54 @@ fun DateReflectionScreen(
} }
@Composable @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( Text(
"Reflect privately — your words stay sealed until you've both shared.", message,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant 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 @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( Text(
"Saved privately 💜 — waiting for ${state.partnerName} to reflect. You'll reveal together.", "Saved privately 💜 — waiting for ${state.partnerName} to reflect. You'll reveal together.",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@ -240,6 +325,9 @@ private fun AwaitingPartner(state: DateReflectionUiState) {
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
state.mine?.let { ReflectionCard(title = "Your reflection", reflection = it) } state.mine?.let { ReflectionCard(title = "Your reflection", reflection = it) }
OutlinedButton(onClick = viewModel::startEdit, modifier = Modifier.fillMaxWidth()) {
Text("Edit my reflection")
}
} }
@Composable @Composable
@ -282,5 +370,6 @@ private fun ReflectionCard(title: String, reflection: DateReflection) {
private fun promptValue(r: DateReflection?, index: Int): String = when (index) { private fun promptValue(r: DateReflection?, index: Int): String = when (index) {
0 -> r?.favoriteMoment.orEmpty() 0 -> r?.favoriteMoment.orEmpty()
1 -> r?.surprise.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 } .any { it.status == "sealed" && it.unlockAt in 1L..now }
}.getOrDefault(false) }.getOrDefault(false)
} }
// Pending date reflection: the most recent completed date this user hasn't // Pending date reflection: true if ANY recent completed date still lacks this
// reflected on yet. The nudge chases the latest date; older un-reflected dates // user's reflection (not just the latest), so an older un-reflected date keeps
// remain reachable from the Replay timeline. // nudging too. Bounded to the most recent dates to cap the reads.
val reflectionJob = async { val reflectionJob = async {
runCatching { runCatching {
val latest = dateMemoryDataSource.getHistoryOnce(coupleId).firstOrNull() dateMemoryDataSource.getHistoryOnce(coupleId)
latest != null && .take(RECENT_DATES_FOR_REFLECTION_NUDGE)
!dateReflectionDataSource.hasReflected(coupleId, latest.id, uid) .any { !dateReflectionDataSource.hasReflected(coupleId, it.id, uid) }
}.getOrDefault(false) }.getOrDefault(false)
} }
val (waitingSession, waitingRoute) = gameJob.await() val (waitingSession, waitingRoute) = gameJob.await()
@ -970,5 +970,7 @@ class HomeViewModel @Inject constructor(
companion object { companion object {
private const val TAG = "HomeViewModel" private const val TAG = "HomeViewModel"
private val STREAK_MILESTONES = listOf(7, 30, 100, 365) 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; 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) { function isValidInviteCode(code) {
// Code must be exactly 6 alphanumeric characters // Code must be exactly 6 alphanumeric characters
return code.matches('^[a-zA-Z0-9]{6}$'); return code.matches('^[a-zA-Z0-9]{6}$');
@ -685,7 +698,14 @@ service cloud.firestore {
&& request.auth.uid == userId && request.auth.uid == userId
&& isCiphertext(request.resource.data.encryptedPayload) && isCiphertext(request.resource.data.encryptedPayload)
&& request.resource.data.keys().hasOnly(['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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AACvC,4DAAmE;AACnE,8DAA8D;AAE9D;;;;;;GAMG;AACU,QAAA,wBAAwB,GAAG,SAAS,CAAC,SAAS;KACxD,QAAQ,CAAC,+DAA+D,CAAC;KACzE,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;;IAClC,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAI5C,CAAA;IAED,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAsC,CAAA;IACvE,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,EAAsC,CAAA;IACrE,8CAA8C;IAC9C,IAAI,MAAM,CAAC,UAAU,KAAK,IAAI,IAAI,KAAK,CAAC,UAAU,KAAK,IAAI;QAAE,OAAM;IAEnE,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,IAAI,CAAA,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,oBAAoB,MAAK,KAAK,EAAE,CAAC;QAC1D,OAAO,CAAC,GAAG,CAAC,8BAA8B,SAAS,gCAAgC,CAAC,CAAA;QACpF,OAAM;IACR,CAAC;IAED,MAAM,KAAK,GAAG,uCAAuC,CAAA;IACrD,MAAM,IAAI,GAAG,kCAAkC,CAAA;IAC/C,MAAM,IAAI,GAAG,wBAAwB,CAAA;IAErC,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,IAAI,IAAA,kCAAqB,EAAC,cAAc,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;QACjD,OAAO,CAAC,GAAG,CAAC,8BAA8B,SAAS,iDAAiD,CAAC,CAAA;QACrG,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,6CAA6C,MAAM,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,MAAM,CAAC,CAAA;IAChH,CAAC,CAAC,CAAA;IACF,MAAM,IAAA,6BAAe,EAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,CAAA;AACvD,CAAC,CAAC,CAAA"}

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); 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")); const admin = __importStar(require("firebase-admin"));
// Initialize the Admin SDK once for every function in this codebase. // Initialize the Admin SDK once for every function in this codebase.
// Handlers call admin.firestore()/messaging() lazily at invocation time, so a // 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; } }); Object.defineProperty(exports, "notifyOnDateMatch", { enumerable: true, get: function () { return createDateMatch_1.notifyOnDateMatch; } });
var onDateReflectionWritten_1 = require("./dates/onDateReflectionWritten"); var onDateReflectionWritten_1 = require("./dates/onDateReflectionWritten");
Object.defineProperty(exports, "onDateReflectionWritten", { enumerable: true, get: function () { return onDateReflectionWritten_1.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"); var onDateHistoryCreated_1 = require("./dates/onDateHistoryCreated");
Object.defineProperty(exports, "onDateHistoryCreated", { enumerable: true, get: function () { return onDateHistoryCreated_1.onDateHistoryCreated; } }); Object.defineProperty(exports, "onDateHistoryCreated", { enumerable: true, get: function () { return onDateHistoryCreated_1.onDateHistoryCreated; } });
var onRestoreRequested_1 = require("./backup/onRestoreRequested"); 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 { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
export { notifyOnDateMatch } from './dates/createDateMatch' export { notifyOnDateMatch } from './dates/createDateMatch'
export { onDateReflectionWritten } from './dates/onDateReflectionWritten' export { onDateReflectionWritten } from './dates/onDateReflectionWritten'
export { onDateReflectionRevealed } from './dates/onDateReflectionRevealed'
export { onDateHistoryCreated } from './dates/onDateHistoryCreated' export { onDateHistoryCreated } from './dates/onDateHistoryCreated'
export { onRestoreRequested, onRestoreFulfilled } from './backup/onRestoreRequested' export { onRestoreRequested, onRestoreFulfilled } from './backup/onRestoreRequested'
export { 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