diff --git a/Future.md b/Future.md index 69b11974..b5b5b06d 100644 --- a/Future.md +++ b/Future.md @@ -284,6 +284,14 @@ no `auth.user().onDelete`). Deploy-day learnings + deferred items: ## Roadmap — features & strategy (consolidated from the old `FUTURE.md`, 2026-06-28) +- **Two-sided date planning (deliberate not-now, 2026-07-16).** The original Date Builder vision — each + partner records preferences (time/budget/duration) and the app assembles a plan from both — was never + built past a write-only stub (N-002). The dead plumbing (`date_plan_preferences` datasource/DAO/rules, + the placeholder `assemblePlanSuggestion`) was REMOVED in Batch F; the Room table `date_plan_preferences` + remains schema-frozen in the asset DB (identity hash) but has no reader/writer. If this returns, design + it fresh: both-partner input, a real merge, and a reader surface — don't resurrect the old plumbing. + Launch checklist: one admin sweep for stray pre-R15 `date_plan_preferences` docs (now default-deny). + **Unbuilt games (Play Hub is live; these remain):** - **Would You Rather / Truth or Dare** — tiered sweet→spicy, consent-gated; reuses the deck + match engine. Strong **premium "spicy" tier** lever. - **Daily Sync / Rose-Bud-Thorn** — one-tap emotional check-in, see partner's; small/new, free retention driver. diff --git a/app/src/main/java/app/closer/data/local/AppDatabase.kt b/app/src/main/java/app/closer/data/local/AppDatabase.kt index c27428fd..2fc6a2d7 100644 --- a/app/src/main/java/app/closer/data/local/AppDatabase.kt +++ b/app/src/main/java/app/closer/data/local/AppDatabase.kt @@ -18,6 +18,8 @@ import app.closer.data.local.entity.QuestionEntity abstract class AppDatabase : RoomDatabase() { abstract fun questionDao(): QuestionDao abstract fun categoryDao(): CategoryDao - abstract fun datePlanPreferenceDao(): DatePlanPreferenceDao + // No DAO for DatePlanPreferenceEntity: the table is schema-frozen dead weight (see its KDoc) — + // it stays in `entities` only because removing it changes the Room identity hash and breaks + // the createFromAsset DB at first open. abstract fun datePlanDao(): DatePlanDao } diff --git a/app/src/main/java/app/closer/data/local/DatePlanPreferenceDao.kt b/app/src/main/java/app/closer/data/local/DatePlanPreferenceDao.kt deleted file mode 100644 index 5cca98ef..00000000 --- a/app/src/main/java/app/closer/data/local/DatePlanPreferenceDao.kt +++ /dev/null @@ -1,32 +0,0 @@ -package app.closer.data.local - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import app.closer.data.local.entity.DatePlanPreferenceEntity - -@Dao -interface DatePlanPreferenceDao { - @Query("SELECT * FROM date_plan_preferences WHERE id = :id LIMIT 1") - suspend fun getById(id: String): DatePlanPreferenceEntity? - - @Query("SELECT * FROM date_plan_preferences WHERE couple_id = :coupleId ORDER BY updated_at DESC") - suspend fun getByCoupleId(coupleId: String): List - - @Query("SELECT * FROM date_plan_preferences WHERE couple_id = :coupleId AND date_idea_id = :dateIdeaId LIMIT 1") - suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreferenceEntity? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(preference: DatePlanPreferenceEntity) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(pREFERENCES: List) - - @Delete - suspend fun delete(preference: DatePlanPreferenceEntity) - - @Query("DELETE FROM date_plan_preferences WHERE couple_id = :coupleId") - suspend fun deleteByCoupleId(coupleId: String) -} diff --git a/app/src/main/java/app/closer/data/local/entity/DatePlanPreferenceEntity.kt b/app/src/main/java/app/closer/data/local/entity/DatePlanPreferenceEntity.kt index be9a7f73..72d892af 100644 --- a/app/src/main/java/app/closer/data/local/entity/DatePlanPreferenceEntity.kt +++ b/app/src/main/java/app/closer/data/local/entity/DatePlanPreferenceEntity.kt @@ -5,12 +5,15 @@ import androidx.room.Entity import androidx.room.PrimaryKey /** - * Room entity for partner date plan preferences. + * ⚠️ SCHEMA-FROZEN DEAD WEIGHT — DO NOT REMOVE (and do not resurrect). * - * Stored locally in Room. Synced to Firestore when explicitly shared/scheduled. - * Both partners contribute preferences; the Builder uses them to assemble a plan. - * - * Table: date_plan_preferences + * Nothing reads or writes this table: the preference feature it served was removed as dead code + * (its DAO, repository methods, Firestore datasource methods and rules block are gone). The entity + * itself must stay registered in [app.closer.data.local.AppDatabase] because Room's identity hash is + * computed from the SCHEMA, and the database ships via createFromAsset with no migrations — removing + * this entity changes the hash and crashes every install at first DB open until the asset DB is + * regenerated (seed/build_db.py) and re-verified. If the two-sided preferences vision ever returns, + * design it fresh (see Future.md) rather than re-wiring this table. */ @Entity(tableName = "date_plan_preferences") data class DatePlanPreferenceEntity( diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDatePlanDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDatePlanDataSource.kt index 92fdf538..fd0066e7 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreDatePlanDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreDatePlanDataSource.kt @@ -3,7 +3,6 @@ package app.closer.data.remote import app.closer.crypto.CoupleEncryptionManager import app.closer.crypto.FieldEncryptor import app.closer.domain.model.DatePlan -import app.closer.domain.model.DatePlanPreference import app.closer.domain.model.DatePlanStatus import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions @@ -19,11 +18,9 @@ import javax.inject.Singleton /** * Firestore data source for date plan preferences and plans. * - * Path layout: - * - couples/{coupleId}/date_plan_preferences/{userId} — per-partner preferences - * - couples/{coupleId}/date_plans/{planId} — complete plans generated by the builder - * - * Local-first: Preferences stored in Room. Plans synced to Firestore when explicitly shared/scheduled. + * Path layout: couples/{coupleId}/date_plans/{planId} — plans, synced when explicitly + * shared/scheduled. (The old per-partner date_plan_preferences surface was removed as dead code; + * the collection is default-deny in rules and no longer exists in code.) */ @Singleton class FirestoreDatePlanDataSource @Inject constructor( @@ -31,53 +28,10 @@ class FirestoreDatePlanDataSource @Inject constructor( private val encryptionManager: CoupleEncryptionManager, private val fieldEncryptor: FieldEncryptor ) { - private fun preferencesRef(coupleId: String) = - db.collection(FirestoreCollections.COUPLES).document(coupleId) - .collection(FirestoreCollections.Couples.DATE_PLAN_PREFERENCES) - private fun plansRef(coupleId: String) = db.collection(FirestoreCollections.COUPLES).document(coupleId) .collection(FirestoreCollections.Couples.DATE_PLANS) - // ─── Preference methods ────────────────────────────────────────────────── - - suspend fun recordPreference(coupleId: String, preference: DatePlanPreference) { - // Strict E2EE: user-entered details are encrypted. dateIdeaId stays plaintext - // (queried via whereEqualTo); dates/timestamps stay plaintext (structural). - val aead = encryptionManager.requireAead(coupleId) - val path = preferencesRef(coupleId).document() - val data = mapOf( - "dateIdeaId" to preference.dateIdeaId, - "preferredDate" to preference.preferredDate, - "preferredTime" to fieldEncryptor.encrypt(preference.preferredTime, aead, coupleId), - "budget" to fieldEncryptor.encrypt(preference.budget.toString(), aead, coupleId), - "duration" to fieldEncryptor.encrypt(preference.duration, aead, coupleId), - "createdAt" to preference.createdAt, - "updatedAt" to preference.updatedAt - ) - path.set(data, SetOptions.merge()).await() - } - - suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreference? { - // Query for preferences on this date idea - val snap = preferencesRef(coupleId) - .whereEqualTo("dateIdeaId", dateIdeaId) - .limit(1) - .get().await() - return snap.documents.firstOrNull()?.toDatePlanPreference(coupleId) - } - - fun observePreferences(coupleId: String): Flow> { - return callbackFlow { - val listener = preferencesRef(coupleId) - .addSnapshotListener { snap, err -> - if (err != null || snap == null) return@addSnapshotListener - trySend(snap.documents.mapNotNull { it.toDatePlanPreference(coupleId) }) - } - awaitClose { listener.remove() } - } - } - // ─── Plan methods ──────────────────────────────────────────────────────── suspend fun createPlan(coupleId: String, plan: DatePlan): String { @@ -145,21 +99,6 @@ class FirestoreDatePlanDataSource @Inject constructor( // ─── Mappers ───────────────────────────────────────────────────────────── - private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlanPreference(coupleId: String): DatePlanPreference? { - val dateIdeaId = getString("dateIdeaId") ?: return null - val aead = encryptionManager.aeadFor(coupleId) - return DatePlanPreference( - id = id, - coupleId = coupleId, - dateIdeaId = dateIdeaId, - preferredDate = (get("preferredDate") as? Number)?.toLong() ?: 0L, - preferredTime = fieldEncryptor.decryptForDisplay(getString("preferredTime"), aead, coupleId) ?: "", - budget = fieldEncryptor.decrypt(getString("budget"), aead, coupleId)?.toIntOrNull() ?: 0, - duration = fieldEncryptor.decryptForDisplay(getString("duration"), aead, coupleId) ?: "", - createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L, - updatedAt = (get("updatedAt") as? Number)?.toLong() ?: 0L - ) - } private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlan(coupleId: String): DatePlan? { val dateIdeaId = getString("dateIdeaId") ?: return null diff --git a/app/src/main/java/app/closer/data/repository/DatePlanRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/DatePlanRepositoryImpl.kt index 8dcb9e78..95076fce 100644 --- a/app/src/main/java/app/closer/data/repository/DatePlanRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/DatePlanRepositoryImpl.kt @@ -1,13 +1,9 @@ package app.closer.data.repository import app.closer.data.local.DatePlanDao -import app.closer.data.local.DatePlanPreferenceDao import app.closer.data.local.entity.DatePlanEntity -import app.closer.data.local.entity.DatePlanPreferenceEntity import app.closer.data.remote.FirestoreDatePlanDataSource import app.closer.domain.model.DatePlan -import app.closer.domain.model.DatePlanPreference -import app.closer.domain.model.DatePlanSuggestion import app.closer.domain.model.DatePlanStatus import app.closer.domain.repository.DatePlanRepository import kotlinx.coroutines.flow.Flow @@ -17,44 +13,15 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Implementation of [DatePlanRepository]. - * - * Local-first: Preferences stored in Room. Plans synced to Firestore when - * explicitly shared/scheduled. Both partners contribute preferences; the - * Builder assembles complete plans from those preferences. + * Implementation of [DatePlanRepository]: local-first plans (Room) synced to Firestore when + * explicitly shared/scheduled. */ @Singleton class DatePlanRepositoryImpl @Inject constructor( - private val preferenceDataSource: FirestoreDatePlanDataSource, private val planDataSource: FirestoreDatePlanDataSource, - private val preferenceDao: DatePlanPreferenceDao, private val planDao: DatePlanDao ) : DatePlanRepository { - // ─── Preference methods ────────────────────────────────────────────────── - - override suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreference? { - // First check local cache - val local = preferenceDao.getPreference(coupleId, dateIdeaId) - if (local != null) return local.toDomain() - - // Fall back to Firestore - return preferenceDataSource.getPreference(coupleId, dateIdeaId) - } - - override suspend fun savePreference(preference: DatePlanPreference) { - // Store in Room for local-first access - val entity = preference.toEntity() - preferenceDao.insert(entity) - - // Sync to Firestore - preferenceDataSource.recordPreference(preference.coupleId, preference) - } - - override fun observePreferences(coupleId: String): Flow> { - return preferenceDataSource.observePreferences(coupleId) - } - // ─── Plan methods ──────────────────────────────────────────────────────── override suspend fun getPlan(planId: String): DatePlan? { @@ -113,26 +80,6 @@ class DatePlanRepositoryImpl @Inject constructor( // In a full implementation, we would fetch the plan's coupleId first } - // ─── Builder methods ───────────────────────────────────────────────────── - - override suspend fun assemblePlanSuggestion( - coupleId: String, - dateIdeaId: String - ): DatePlanSuggestion? { - // Get preferences from both partners for this date idea - val partnerAPreference = preferenceDao.getPreference(coupleId, dateIdeaId) - val partnerBPreference = /* Get the other partner's preference */ - - // For now, return a placeholder suggestion - // In a full implementation, this would merge preferences from both partners - return DatePlanSuggestion( - activity = "Based on the selected date idea", - food = "Suggested dining option based on budget", - conversationPrompts = listOf("What made this date special?"), - optionalChallenge = "Try something new together" - ) - } - // ─── Sanitization ──────────────────────────────────────────────────────── private fun DatePlan.sanitized(): DatePlan = copy( @@ -156,30 +103,6 @@ class DatePlanRepositoryImpl @Inject constructor( const val MAX_PROMPTS = 20 } - private fun DatePlanPreferenceEntity.toDomain(): DatePlanPreference = DatePlanPreference( - id = id, - coupleId = coupleId, - dateIdeaId = dateIdeaId, - preferredDate = preferredDate, - preferredTime = preferredTime, - budget = budget, - duration = duration, - createdAt = createdAt, - updatedAt = updatedAt - ) - - private fun DatePlanPreference.toEntity(): DatePlanPreferenceEntity = DatePlanPreferenceEntity( - id = id, - coupleId = coupleId, - dateIdeaId = dateIdeaId, - preferredDate = preferredDate, - preferredTime = preferredTime, - budget = budget, - duration = duration, - createdAt = createdAt, - updatedAt = updatedAt - ) - private fun DatePlanEntity.toDomain(): DatePlan = DatePlan( id = id, coupleId = coupleId, diff --git a/app/src/main/java/app/closer/di/DatabaseModule.kt b/app/src/main/java/app/closer/di/DatabaseModule.kt index 0ec17431..52e4b405 100644 --- a/app/src/main/java/app/closer/di/DatabaseModule.kt +++ b/app/src/main/java/app/closer/di/DatabaseModule.kt @@ -40,10 +40,6 @@ object DatabaseModule { @Singleton fun provideCategoryDao(db: AppDatabase) = db.categoryDao() - @Provides - @Singleton - fun provideDatePlanPreferenceDao(db: AppDatabase) = db.datePlanPreferenceDao() - @Provides @Singleton fun provideDatePlanDao(db: AppDatabase) = db.datePlanDao() diff --git a/app/src/main/java/app/closer/domain/model/DatePlanPreference.kt b/app/src/main/java/app/closer/domain/model/DatePlanPreference.kt deleted file mode 100644 index 3912afe5..00000000 --- a/app/src/main/java/app/closer/domain/model/DatePlanPreference.kt +++ /dev/null @@ -1,21 +0,0 @@ -package app.closer.domain.model - -/** - * A partner's preferences for assembling a date plan. - * - * Both partners contribute preferences; the Builder uses them to assemble - * a complete plan with activity, food, conversation prompts, and optional challenge. - * - * Stored locally in Room. Synced to Firestore when explicitly shared/scheduled. - */ -data class DatePlanPreference( - val id: String = "", - val coupleId: String = "", - val dateIdeaId: String = "", - val preferredDate: Long = 0L, - val preferredTime: String = "", - val budget: Int = 0, - val duration: String = "", - val createdAt: Long = 0L, - val updatedAt: Long = 0L -) diff --git a/app/src/main/java/app/closer/domain/model/DatePlanSuggestion.kt b/app/src/main/java/app/closer/domain/model/DatePlanSuggestion.kt deleted file mode 100644 index 139a72a0..00000000 --- a/app/src/main/java/app/closer/domain/model/DatePlanSuggestion.kt +++ /dev/null @@ -1,19 +0,0 @@ -package app.closer.domain.model - -/** - * A complete plan suggestion generated by the Date Builder. - * - * Contains the activity, food, conversation prompts, and optional challenge - * that are assembled from partner preferences and the selected date idea. - * - * @property activity Recommended activity description. - * @property food Recommended food/dining option. - * @property conversationPrompts List of conversation question IDs or prompts. - * @property optionalChallenge Optional challenge suggestion for the date. - */ -data class DatePlanSuggestion( - val activity: String = "", - val food: String = "", - val conversationPrompts: List = emptyList(), - val optionalChallenge: String? = null -) diff --git a/app/src/main/java/app/closer/domain/repository/DatePlanRepository.kt b/app/src/main/java/app/closer/domain/repository/DatePlanRepository.kt index c941afab..9011205f 100644 --- a/app/src/main/java/app/closer/domain/repository/DatePlanRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/DatePlanRepository.kt @@ -1,31 +1,17 @@ package app.closer.domain.repository import app.closer.domain.model.DatePlan -import app.closer.domain.model.DatePlanPreference -import app.closer.domain.model.DatePlanSuggestion -import kotlinx.coroutines.flow.Flow /** - * Repository for the Date Builder feature. + * Repository for the Date Builder feature: real, schedulable [DatePlan]s synced to Firestore. * - * Responsibilities: - * - Store partner preferences locally (Room) - * - Assemble complete date plans from preferences - * - Sync plans to Firestore when shared/scheduled + * The old two-sided "preference" surface (each partner records preferences, a plan is assembled + * from both) was removed as dead code — nothing ever read it, and "Create Plan" has written real + * PLANNED plans since the N-002 fix. If that two-sided product vision returns, design it fresh + * rather than resurrecting the old plumbing (see Future.md). */ interface DatePlanRepository { - // ─── Preference methods ────────────────────────────────────────────────── - - /** Get a partner's preference for a date idea. */ - suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreference? - - /** Record or update a partner's preference. */ - suspend fun savePreference(preference: DatePlanPreference) - - /** Observe all preferences for a couple. */ - fun observePreferences(coupleId: String): Flow> - // ─── Plan methods ──────────────────────────────────────────────────────── /** Get a date plan by ID. */ @@ -45,12 +31,4 @@ interface DatePlanRepository { /** Delete a date plan. */ suspend fun deletePlan(planId: String) - - // ─── Builder methods ───────────────────────────────────────────────────── - - /** Assemble a date plan suggestion from partner preferences. */ - suspend fun assemblePlanSuggestion( - coupleId: String, - dateIdeaId: String - ): DatePlanSuggestion? } diff --git a/app/src/main/java/app/closer/ui/dates/DateBuilderScreen.kt b/app/src/main/java/app/closer/ui/dates/DateBuilderScreen.kt index 79fcac63..08ff4c2d 100644 --- a/app/src/main/java/app/closer/ui/dates/DateBuilderScreen.kt +++ b/app/src/main/java/app/closer/ui/dates/DateBuilderScreen.kt @@ -88,7 +88,7 @@ fun DateBuilderScreen( onTimeChange = viewModel::updateTime, onBudgetChange = viewModel::updateBudget, onDurationChange = viewModel::updateDuration, - onSave = { viewModel.savePreference() }, + onSave = { viewModel.createPlan() }, onBack = { onNavigate("back") } ) SnackbarHost( diff --git a/app/src/main/java/app/closer/ui/dates/DateBuilderViewModel.kt b/app/src/main/java/app/closer/ui/dates/DateBuilderViewModel.kt index f8c386be..ed523686 100644 --- a/app/src/main/java/app/closer/ui/dates/DateBuilderViewModel.kt +++ b/app/src/main/java/app/closer/ui/dates/DateBuilderViewModel.kt @@ -42,7 +42,7 @@ class DateBuilderViewModel @Inject constructor( _uiState.update { it.copy(duration = duration) } } - fun savePreference() { + fun createPlan() { val state = _uiState.value viewModelScope.launch { diff --git a/firestore.rules b/firestore.rules index e2779a51..498aa5b0 100644 --- a/firestore.rules +++ b/firestore.rules @@ -601,23 +601,8 @@ service cloud.firestore { allow update, delete: if false; } - // Date plan preferences: per-partner preferences for building date plans. - // Both members can read; either member can write a preference document. - // Document IDs are Firestore auto-IDs (not user IDs). - match /date_plan_preferences/{prefId} { - allow read: if isCouplesMember(coupleId); - allow create, update: if isCouplesMember(coupleId) - && request.resource.data.keys().hasAll(['dateIdeaId', 'createdAt', 'updatedAt']) - && request.resource.data.keys().hasOnly([ - 'dateIdeaId', 'preferredDate', 'preferredTime', - 'budget', 'duration', 'createdAt', 'updatedAt' - ]) - // Strict E2EE: user-entered details are ciphertext (dateIdeaId/dates stay plaintext for queries). - && cipherOrAbsent(request.resource.data, 'preferredTime') - && cipherOrAbsent(request.resource.data, 'budget') - && cipherOrAbsent(request.resource.data, 'duration'); - allow delete: if false; - } + // (date_plan_preferences removed 2026-07-16 — the per-partner preference surface was dead code; + // the collection is deliberately default-deny. Stray pre-R15 docs are orphaned: launch checklist.) // Date plans: complete plans assembled from partner preferences. // Both members can read and delete; writes are field-validated.