feat: date reflection reveal, UI upgrade plan, seed updates, ime-scan script
This commit is contained in:
parent
222bbd1c57
commit
896bf26b28
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"onDateReflectionRevealed.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionRevealed.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue