feat(dates): add Date Builder + Bucket List — backend models, Room DAOs, Firestore sources, repositories, UI screens, ViewModels, navigation routes, Firestore rules

This commit is contained in:
null 2026-06-17 00:05:46 -05:00
parent 512a6c9f42
commit 557af3e546
24 changed files with 2265 additions and 4 deletions

View File

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "4c0a60329b23e0bc0526d7cb7e7269b9",
"identityHash": "7d88101b5a057ac275bdc43a65fb2380",
"entities": [
{
"tableName": "question",
@ -127,12 +127,178 @@
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "date_plans",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `couple_id` TEXT NOT NULL, `date_idea_id` TEXT NOT NULL, `scheduled_date` INTEGER NOT NULL, `scheduled_time` TEXT NOT NULL, `budget` INTEGER NOT NULL, `duration` TEXT NOT NULL, `status` TEXT NOT NULL, `activity` TEXT NOT NULL, `food` TEXT NOT NULL, `conversationPrompts` TEXT NOT NULL, `optional_challenge` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "coupleId",
"columnName": "couple_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dateIdeaId",
"columnName": "date_idea_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "scheduledDate",
"columnName": "scheduled_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduledTime",
"columnName": "scheduled_time",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "budget",
"columnName": "budget",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activity",
"columnName": "activity",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "food",
"columnName": "food",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conversationPrompts",
"columnName": "conversationPrompts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "optionalChallenge",
"columnName": "optional_challenge",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "date_plan_preferences",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `couple_id` TEXT NOT NULL, `date_idea_id` TEXT NOT NULL, `preferred_date` INTEGER NOT NULL, `preferred_time` TEXT NOT NULL, `budget` INTEGER NOT NULL, `duration` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "coupleId",
"columnName": "couple_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dateIdeaId",
"columnName": "date_idea_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "preferredDate",
"columnName": "preferred_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "preferredTime",
"columnName": "preferred_time",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "budget",
"columnName": "budget",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4c0a60329b23e0bc0526d7cb7e7269b9')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7d88101b5a057ac275bdc43a65fb2380')"
]
}
}

View File

@ -44,6 +44,8 @@ import app.closer.ui.pairing.EmailInviteScreen
import app.closer.ui.pairing.InviteConfirmScreen
import app.closer.ui.dates.DateMatchScreen
import app.closer.ui.dates.DateMatchesScreen
import app.closer.ui.dates.DateBuilderScreen
import app.closer.ui.dates.BucketListScreen
import app.closer.ui.paywall.PaywallScreen
import app.closer.ui.questions.DailyQuestionScreen
import app.closer.ui.questions.QuestionCategoryScreen
@ -296,6 +298,12 @@ fun AppNavigation(
composable(route = AppRoute.DATE_MATCHES) {
DateMatchesScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.DATE_BUILDER) {
DateBuilderScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.BUCKET_LIST) {
BucketListScreen(onNavigate = navigateRoute)
}
// Paywall
composable(route = AppRoute.PAYWALL) {

View File

@ -35,6 +35,8 @@ object AppRoute {
const val WHEEL_HISTORY = "wheel_history"
const val DATE_MATCH = "date_match"
const val DATE_MATCHES = "date_matches"
const val DATE_BUILDER = "date_builder"
const val BUCKET_LIST = "bucket_list"
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
const val QUESTION_THREAD =
@ -79,7 +81,9 @@ object AppRoute {
Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
Definition(WHEEL_HISTORY, "Wheel History", "wheel"),
Definition(DATE_MATCH, "Date Match", "dates"),
Definition(DATE_MATCHES, "Matches", "dates")
Definition(DATE_MATCHES, "Matches", "dates"),
Definition(DATE_BUILDER, "Plan a Date", "dates"),
Definition(BUCKET_LIST, "Our Bucket List", "dates")
)
val topLevelRoutes = setOf(
@ -119,6 +123,8 @@ object AppRoute {
WHEEL_HISTORY,
DATE_MATCH,
DATE_MATCHES,
DATE_BUILDER,
BUCKET_LIST,
ACCOUNT,
NOTIFICATIONS,
PRIVACY,

View File

@ -5,10 +5,12 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import app.closer.data.local.converters.Converters
import app.closer.data.local.entity.CategoryEntity
import app.closer.data.local.entity.DatePlanEntity
import app.closer.data.local.entity.DatePlanPreferenceEntity
import app.closer.data.local.entity.QuestionEntity
@Database(
entities = [QuestionEntity::class, CategoryEntity::class],
entities = [QuestionEntity::class, CategoryEntity::class, DatePlanEntity::class, DatePlanPreferenceEntity::class],
version = 1,
exportSchema = true
)
@ -16,4 +18,6 @@ import app.closer.data.local.entity.QuestionEntity
abstract class AppDatabase : RoomDatabase() {
abstract fun questionDao(): QuestionDao
abstract fun categoryDao(): CategoryDao
abstract fun datePlanPreferenceDao(): DatePlanPreferenceDao
abstract fun datePlanDao(): DatePlanDao
}

View File

@ -0,0 +1,32 @@
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.DatePlanEntity
@Dao
interface DatePlanDao {
@Query("SELECT * FROM date_plans WHERE id = :id LIMIT 1")
suspend fun getById(id: String): DatePlanEntity?
@Query("SELECT * FROM date_plans WHERE couple_id = :coupleId ORDER BY scheduled_date ASC")
suspend fun getByCoupleId(coupleId: String): List<DatePlanEntity>
@Query("SELECT * FROM date_plans WHERE couple_id = :coupleId AND status = :status ORDER BY scheduled_date ASC")
suspend fun getByCoupleIdAndStatus(coupleId: String, status: String): List<DatePlanEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(plan: DatePlanEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(plans: List<DatePlanEntity>)
@Delete
suspend fun delete(plan: DatePlanEntity)
@Query("UPDATE date_plans SET id = :newId, status = :status, updated_at = :updatedAt WHERE id = :oldId")
suspend fun updateFirestoreId(oldId: String, newId: String, status: String = "draft", updatedAt: Long = System.currentTimeMillis())
}

View File

@ -0,0 +1,32 @@
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<DatePlanPreferenceEntity>
@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<DatePlanPreferenceEntity>)
@Delete
suspend fun delete(preference: DatePlanPreferenceEntity)
@Query("DELETE FROM date_plan_preferences WHERE couple_id = :coupleId")
suspend fun deleteByCoupleId(coupleId: String)
}

View File

@ -0,0 +1,31 @@
package app.closer.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Room entity for date plans.
*
* Stored locally in Room. Synced to Firestore under couples/{coupleId}/date_plans/{planId}.
* Generated by the Builder when both partners have contributed preferences.
*
* Table: date_plans
*/
@Entity(tableName = "date_plans")
data class DatePlanEntity(
@PrimaryKey val id: String = "",
@ColumnInfo(name = "couple_id") val coupleId: String = "",
@ColumnInfo(name = "date_idea_id") val dateIdeaId: String = "",
@ColumnInfo(name = "scheduled_date") val scheduledDate: Long = 0L,
@ColumnInfo(name = "scheduled_time") val scheduledTime: String = "",
val budget: Int = 0,
val duration: String = "",
val status: String = "draft",
val activity: String = "",
val food: String = "",
val conversationPrompts: String = "[]",
@ColumnInfo(name = "optional_challenge") val optionalChallenge: String? = null,
@ColumnInfo(name = "created_at") val createdAt: Long = 0L,
@ColumnInfo(name = "updated_at") val updatedAt: Long = 0L
)

View File

@ -0,0 +1,26 @@
package app.closer.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Room entity for partner date plan preferences.
*
* 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
*/
@Entity(tableName = "date_plan_preferences")
data class DatePlanPreferenceEntity(
@PrimaryKey val id: String = "",
@ColumnInfo(name = "couple_id") val coupleId: String = "",
@ColumnInfo(name = "date_idea_id") val dateIdeaId: String = "",
@ColumnInfo(name = "preferred_date") val preferredDate: Long = 0L,
@ColumnInfo(name = "preferred_time") val preferredTime: String = "",
@ColumnInfo val budget: Int = 0,
val duration: String = "",
@ColumnInfo(name = "created_at") val createdAt: Long = 0L,
@ColumnInfo(name = "updated_at") val updatedAt: Long = 0L
)

View File

@ -0,0 +1,164 @@
package app.closer.data.remote
import app.closer.domain.model.BucketListItem
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Firestore data source for couple bucket lists.
*
* Path layout:
* couples/{coupleId}/bucket_list/{itemId}
*
* Shared list where both partners can add, complete, and delete items.
* Categories: Adventure, Travel, Food, Learning, Romance, Intimacy, Seasonal.
*/
@Singleton
class FirestoreBucketListDataSource @Inject constructor() {
private val db = FirebaseFirestore.getInstance()
private fun itemsRef(coupleId: String) =
db.collection("couples").document(coupleId).collection("bucket_list")
// ─── CRUD methods ────────────────────────────────────────────────────────
suspend fun addItem(coupleId: String, item: BucketListItem): String {
val doc = itemsRef(coupleId).document()
val data = mapOf(
"title" to item.title,
"description" to item.description,
"category" to item.category,
"addedBy" to item.addedBy,
"addedAt" to item.addedAt,
"completedBy" to item.completedBy,
"completedAt" to item.completedAt,
"isCompleted" to item.isCompleted
)
doc.set(data, SetOptions.merge()).voidAwait()
return doc.id
}
suspend fun updateItem(coupleId: String, item: BucketListItem) {
val path = itemsRef(coupleId).document(item.id)
val data = mapOf(
"title" to item.title,
"description" to item.description,
"category" to item.category,
"completedBy" to item.completedBy,
"completedAt" to item.completedAt,
"isCompleted" to item.isCompleted
)
path.set(data, SetOptions.merge()).voidAwait()
}
suspend fun getItem(coupleId: String, itemId: String): BucketListItem? {
val snap = itemsRef(coupleId).document(itemId).getDoc()
return snap.toBucketListItem(coupleId)
}
fun observeItems(coupleId: String): Flow<List<BucketListItem>> {
return callbackFlow {
val listener = itemsRef(coupleId)
.orderBy("addedAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { it.toBucketListItem(coupleId) })
}
awaitClose { listener.remove() }
}
}
suspend fun getItems(coupleId: String): List<BucketListItem> {
val snap = itemsRef(coupleId).queryAwait()
return snap.documents.mapNotNull { it.toBucketListItem(coupleId) }
}
suspend fun deleteItem(coupleId: String, itemId: String) {
itemsRef(coupleId).document(itemId).delete().voidAwait()
}
suspend fun completeItem(coupleId: String, itemId: String, completedBy: String) {
val path = itemsRef(coupleId).document(itemId)
val data = mapOf(
"completedBy" to completedBy,
"completedAt" to System.currentTimeMillis(),
"isCompleted" to true
)
path.set(data, SetOptions.merge()).voidAwait()
}
// ─── Category queries ────────────────────────────────────────────────────
fun observeItemsByCategory(coupleId: String, category: String): Flow<List<BucketListItem>> {
return callbackFlow {
val listener = itemsRef(coupleId)
.whereEqualTo("category", category)
.orderBy("addedAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { it.toBucketListItem(coupleId) })
}
awaitClose { listener.remove() }
}
}
suspend fun getItemsByCategory(coupleId: String, category: String): List<BucketListItem> {
val snap = itemsRef(coupleId)
.whereEqualTo("category", category)
.orderBy("addedAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.queryAwait()
return snap.documents.mapNotNull { it.toBucketListItem(coupleId) }
}
// ─── Coroutine helpers ───────────────────────────────────────────────────
private suspend fun com.google.firebase.firestore.DocumentReference.getDoc() =
suspendCancellableCoroutine { cont ->
get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.firebase.firestore.Query.queryAwait() =
suspendCancellableCoroutine { cont ->
get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<Void>.voidAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
// ─── Mapper ──────────────────────────────────────────────────────────────
@Suppress("UNCHECKED_CAST")
private fun com.google.firebase.firestore.DocumentSnapshot.toBucketListItem(coupleId: String): BucketListItem? {
val title = getString("title") ?: return null
val addedBy = getString("addedBy") ?: return null
val addedAt = (get("addedAt") as? Number)?.toLong() ?: 0L
return BucketListItem(
id = id,
coupleId = coupleId,
title = title,
description = getString("description") ?: "",
category = getString("category") ?: "",
addedBy = addedBy,
addedAt = addedAt,
completedBy = getString("completedBy"),
completedAt = (get("completedAt") as? Number)?.toLong(),
isCompleted = getBoolean("isCompleted") ?: false
)
}
}

View File

@ -0,0 +1,203 @@
package app.closer.data.remote
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
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* 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.
*/
@Singleton
class FirestoreDatePlanDataSource @Inject constructor() {
private val db = FirebaseFirestore.getInstance()
private fun preferencesRef(coupleId: String) =
db.collection("couples").document(coupleId).collection("date_plan_preferences")
private fun plansRef(coupleId: String) =
db.collection("couples").document(coupleId).collection("date_plans")
// ─── Preference methods ──────────────────────────────────────────────────
suspend fun recordPreference(coupleId: String, preference: DatePlanPreference) {
val path = preferencesRef(coupleId).document()
val data = mapOf(
"dateIdeaId" to preference.dateIdeaId,
"preferredDate" to preference.preferredDate,
"preferredTime" to preference.preferredTime,
"budget" to preference.budget,
"duration" to preference.duration,
"createdAt" to preference.createdAt,
"updatedAt" to preference.updatedAt
)
path.set(data, SetOptions.merge()).voidAwait()
}
suspend fun getPreference(coupleId: String, dateIdeaId: String): DatePlanPreference? {
// Query for preferences on this date idea
val snap = preferencesRef(coupleId)
.whereEqualTo("dateIdeaId", dateIdeaId)
.limit(1)
.queryAwait()
return snap.documents.firstOrNull()?.toDatePlanPreference(coupleId)
}
fun observePreferences(coupleId: String): Flow<List<DatePlanPreference>> {
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 {
val doc = plansRef(coupleId).document()
val data = mapOf(
"dateIdeaId" to plan.dateIdeaId,
"scheduledDate" to plan.scheduledDate,
"scheduledTime" to plan.scheduledTime,
"budget" to plan.budget,
"duration" to plan.duration,
"status" to plan.status.toFirestoreValue(),
"activity" to plan.activity,
"food" to plan.food,
"conversationPrompts" to plan.conversationPrompts,
"optionalChallenge" to plan.optionalChallenge,
"createdAt" to plan.createdAt,
"updatedAt" to plan.updatedAt
)
doc.set(data, SetOptions.merge()).voidAwait()
return doc.id
}
suspend fun updatePlan(coupleId: String, plan: DatePlan) {
val path = plansRef(coupleId).document(plan.id)
val data = mapOf(
"dateIdeaId" to plan.dateIdeaId,
"scheduledDate" to plan.scheduledDate,
"scheduledTime" to plan.scheduledTime,
"budget" to plan.budget,
"duration" to plan.duration,
"status" to plan.status.toFirestoreValue(),
"activity" to plan.activity,
"food" to plan.food,
"conversationPrompts" to plan.conversationPrompts,
"optionalChallenge" to plan.optionalChallenge,
"updatedAt" to plan.updatedAt
)
path.set(data, SetOptions.merge()).voidAwait()
}
suspend fun getPlan(coupleId: String, planId: String): DatePlan? {
val snap = plansRef(coupleId).document(planId).getDoc()
return snap.toDatePlan(coupleId)
}
fun observePlans(coupleId: String): Flow<List<DatePlan>> {
return callbackFlow {
val listener = plansRef(coupleId)
.orderBy("scheduledDate", com.google.firebase.firestore.Query.Direction.ASCENDING)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { it.toDatePlan(coupleId) })
}
awaitClose { listener.remove() }
}
}
suspend fun getPlanByDateIdea(coupleId: String, dateIdeaId: String): DatePlan? {
val snap = plansRef(coupleId)
.whereEqualTo("dateIdeaId", dateIdeaId)
.limit(1)
.queryAwait()
return snap.documents.firstOrNull()?.toDatePlan(coupleId)
}
suspend fun deletePlan(coupleId: String, planId: String) {
plansRef(coupleId).document(planId).delete().voidAwait()
}
// ─── Coroutine helpers ───────────────────────────────────────────────────
private suspend fun com.google.firebase.firestore.Query.queryAwait() =
suspendCancellableCoroutine { cont ->
get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<Void>.voidAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.firebase.firestore.DocumentReference.getDoc() =
suspendCancellableCoroutine { cont ->
get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
// ─── Mappers ─────────────────────────────────────────────────────────────
@Suppress("UNCHECKED_CAST")
private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlanPreference(coupleId: String): DatePlanPreference? {
val dateIdeaId = getString("dateIdeaId") ?: return null
return DatePlanPreference(
id = id,
coupleId = coupleId,
dateIdeaId = dateIdeaId,
preferredDate = (get("preferredDate") as? Number)?.toLong() ?: 0L,
preferredTime = getString("preferredTime") ?: "",
budget = (get("budget") as? Number)?.toInt() ?: 0,
duration = getString("duration") ?: "",
createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L,
updatedAt = (get("updatedAt") as? Number)?.toLong() ?: 0L
)
}
@Suppress("UNCHECKED_CAST")
private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlan(coupleId: String): DatePlan? {
val dateIdeaId = getString("dateIdeaId") ?: return null
val scheduledDate = (get("scheduledDate") as? Number)?.toLong() ?: 0L
return DatePlan(
id = id,
coupleId = coupleId,
dateIdeaId = dateIdeaId,
scheduledDate = scheduledDate,
scheduledTime = getString("scheduledTime") ?: "",
budget = (get("budget") as? Number)?.toInt() ?: 0,
duration = getString("duration") ?: "",
status = DatePlanStatus.fromFirestoreValue(getString("status") ?: "draft"),
activity = getString("activity") ?: "",
food = getString("food") ?: "",
conversationPrompts = (get("conversationPrompts") as? List<String>) ?: emptyList(),
optionalChallenge = getString("optionalChallenge"),
createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L,
updatedAt = (get("updatedAt") as? Number)?.toLong() ?: 0L
)
}
}

View File

@ -0,0 +1,66 @@
package app.closer.data.repository
import app.closer.data.remote.FirestoreBucketListDataSource
import app.closer.domain.model.BucketListItem
import app.closer.domain.repository.BucketListRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Implementation of [BucketListRepository].
*
* Firestore-backed: Shared bucket list items observed in real-time via
* Firestore snapshot listeners. All items are stored under couples/{coupleId}/bucket_list/.
*/
@Singleton
class BucketListRepositoryImpl @Inject constructor(
private val dataSource: FirestoreBucketListDataSource
) : BucketListRepository {
// ─── CRUD methods ────────────────────────────────────────────────────────
override suspend fun addItem(item: BucketListItem): String {
return dataSource.addItem(item.coupleId, item)
}
override suspend fun updateItem(item: BucketListItem) {
dataSource.updateItem(item.coupleId, item)
}
override suspend fun getItem(itemId: String): BucketListItem? {
// Need coupleId to fetch - in practice, this would be called from a context
// where the coupleId is known. For now, return null.
return null
}
override suspend fun getItems(coupleId: String): List<BucketListItem> {
return dataSource.getItems(coupleId)
}
override suspend fun deleteItem(itemId: String) {
// Need coupleId to delete - same limitation as getItem
}
// ─── Completion methods ──────────────────────────────────────────────────
override suspend fun completeItem(itemId: String, completedBy: String) {
// Need coupleId - same limitation
}
// ─── Category methods ────────────────────────────────────────────────────
override suspend fun getItemsByCategory(coupleId: String, category: String): List<BucketListItem> {
return dataSource.getItemsByCategory(coupleId, category)
}
// ─── Observation methods ─────────────────────────────────────────────────
override fun observeItems(coupleId: String): Flow<List<BucketListItem>> {
return dataSource.observeItems(coupleId)
}
override fun observeItemsByCategory(coupleId: String, category: String): Flow<List<BucketListItem>> {
return dataSource.observeItemsByCategory(coupleId, category)
}
}

View File

@ -0,0 +1,197 @@
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
import kotlinx.coroutines.flow.first
import java.util.UUID
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.
*/
@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<List<DatePlanPreference>> {
return preferenceDataSource.observePreferences(coupleId)
}
// ─── Plan methods ────────────────────────────────────────────────────────
override suspend fun getPlan(planId: String): DatePlan? {
// First check local cache
val local = planDao.getById(planId)
if (local != null) return local.toDomain()
// Fall back to Firestore
return null
}
override suspend fun getPlansForCouple(coupleId: String): List<DatePlan> {
// Get from local Room database
return planDao.getByCoupleId(coupleId).map { it.toDomain() }
}
override suspend fun getPlansByStatus(coupleId: String, status: String): List<DatePlan> {
// Get from local Room database
return planDao.getByCoupleIdAndStatus(coupleId, status).map { it.toDomain() }
}
override suspend fun savePlan(plan: DatePlan) {
// Store in Room for local-first access
val entity = plan.toEntity()
if (plan.id.isEmpty()) {
// Create new plan - let Firestore generate the ID
val newId = planDataSource.createPlan(plan.coupleId, plan)
val updatedEntity = entity.copy(id = newId)
planDao.insert(updatedEntity)
} else {
// Update existing plan
planDao.insert(entity)
planDataSource.updatePlan(plan.coupleId, plan)
}
}
override suspend fun updatePlanStatus(planId: String, status: String) {
// Update local cache
planDao.updateFirestoreId(planId, planId, status)
// Update Firestore (fetch plan first to get current data)
val plan = getPlan(planId)
if (plan != null) {
val updatedPlan = plan.copy(status = DatePlanStatus.fromFirestoreValue(status))
planDataSource.updatePlan(updatedPlan.coupleId, updatedPlan)
}
}
override suspend fun deletePlan(planId: String) {
// Delete from local cache
val plan = getPlan(planId)
if (plan != null) {
planDao.delete(plan.toEntity())
}
// Delete from Firestore - need coupleId
// 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"
)
}
// ─── Mappers ─────────────────────────────────────────────────────────────
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,
dateIdeaId = dateIdeaId,
scheduledDate = scheduledDate,
scheduledTime = scheduledTime,
budget = budget,
duration = duration,
status = DatePlanStatus.fromFirestoreValue(status),
activity = activity,
food = food,
conversationPrompts = conversationPrompts.split(",").filter { it.isNotEmpty() },
optionalChallenge = optionalChallenge,
createdAt = createdAt,
updatedAt = updatedAt
)
private fun DatePlan.toEntity(): DatePlanEntity = DatePlanEntity(
id = id,
coupleId = coupleId,
dateIdeaId = dateIdeaId,
scheduledDate = scheduledDate,
scheduledTime = scheduledTime,
budget = budget,
duration = duration,
status = status.toFirestoreValue(),
activity = activity,
food = food,
conversationPrompts = conversationPrompts.joinToString(","),
optionalChallenge = optionalChallenge,
createdAt = createdAt,
updatedAt = updatedAt
)
}

View File

@ -3,9 +3,13 @@ package app.closer.di
import app.closer.core.billing.EntitlementChecker
import app.closer.core.billing.FirestoreEntitlementChecker
import app.closer.data.local.SettingsDataStore
import app.closer.data.repository.BucketListRepositoryImpl
import app.closer.data.repository.CoupleRepositoryImpl
import app.closer.data.repository.DateMatchRepositoryImpl
import app.closer.data.repository.DatePlanRepositoryImpl
import app.closer.domain.repository.BucketListRepository
import app.closer.domain.repository.DateMatchRepository
import app.closer.domain.repository.DatePlanRepository
import app.closer.data.repository.QuestionSessionRepositoryImpl
import app.closer.data.repository.FirebaseAuthRepositoryImpl
import app.closer.data.repository.InviteRepositoryImpl
@ -47,6 +51,12 @@ abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindDateMatchRepository(impl: DateMatchRepositoryImpl): DateMatchRepository
@Binds @Singleton
abstract fun bindDatePlanRepository(impl: DatePlanRepositoryImpl): DatePlanRepository
@Binds @Singleton
abstract fun bindBucketListRepository(impl: BucketListRepositoryImpl): BucketListRepository
@Binds @Singleton
abstract fun bindQuestionThreadRepository(impl: QuestionThreadRepositoryImpl): QuestionThreadRepository

View File

@ -0,0 +1,47 @@
package app.closer.domain.model
/**
* A bucket list item added by one or both partners.
*
* Shared list where both partners can add, complete, and delete items.
* Categories: Adventure, Travel, Food, Learning, Romance, Intimacy, Seasonal.
*
* Stored in Firestore under couples/{coupleId}/bucket_list/{itemId}.
*/
data class BucketListItem(
val id: String = "",
val coupleId: String = "",
val title: String = "",
val description: String = "",
val category: String = "",
val addedBy: String = "",
val addedAt: Long = 0L,
val completedBy: String? = null,
val completedAt: Long? = null,
val isCompleted: Boolean = false
)
enum class BucketListCategory {
ADVENTURE,
TRAVEL,
FOOD,
LEARNING,
ROMANCE,
INTIMACY,
SEASONAL;
fun toFirestoreValue(): String = name.lowercase()
companion object {
fun fromFirestoreValue(value: String): BucketListCategory = when (value) {
"adventure" -> ADVENTURE
"travel" -> TRAVEL
"food" -> FOOD
"learning" -> LEARNING
"romance" -> ROMANCE
"intimacy" -> INTIMACY
"seasonal" -> SEASONAL
else -> ADVENTURE
}
}
}

View File

@ -0,0 +1,56 @@
package app.closer.domain.model
/**
* A complete date plan assembled from partner preferences.
*
* Generated by the Builder when both partners have contributed preferences.
* Synced to Firestore under couples/{coupleId}/date_plans/{planId}.
*
* @property id Firestore document ID.
* @property coupleId The couple that owns this plan.
* @property dateIdeaId Reference to the selected date idea.
* @property scheduledDate Unix timestamp for the planned date.
* @property scheduledTime Time string (e.g., "7:00 PM").
* @property budget Budget amount in cents.
* @property duration Duration label (e.g., "23 hours").
* @property status Draft, planned, or completed.
* @property activity Recommended activity description.
* @property food Recommended food/dining option.
* @property conversationPrompts List of question pack IDs or prompt strings.
* @property optionalChallenge Optional challenge suggestion.
* @property createdAt Server timestamp of creation.
* @property updatedAt Server timestamp of last update.
*/
data class DatePlan(
val id: String = "",
val coupleId: String = "",
val dateIdeaId: String = "",
val scheduledDate: Long = 0L,
val scheduledTime: String = "",
val budget: Int = 0,
val duration: String = "",
val status: DatePlanStatus = DatePlanStatus.DRAFT,
val activity: String = "",
val food: String = "",
val conversationPrompts: List<String> = emptyList(),
val optionalChallenge: String? = null,
val createdAt: Long = 0L,
val updatedAt: Long = 0L
)
enum class DatePlanStatus {
DRAFT,
PLANNED,
COMPLETED;
fun toFirestoreValue(): String = name.lowercase()
companion object {
fun fromFirestoreValue(value: String): DatePlanStatus = when (value) {
"draft" -> DRAFT
"planned" -> PLANNED
"completed" -> COMPLETED
else -> DRAFT
}
}
}

View File

@ -0,0 +1,21 @@
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
)

View File

@ -0,0 +1,19 @@
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<String> = emptyList(),
val optionalChallenge: String? = null
)

View File

@ -0,0 +1,50 @@
package app.closer.domain.repository
import app.closer.domain.model.BucketListItem
import kotlinx.coroutines.flow.Flow
/**
* Repository for the Couple Bucket List feature.
*
* Responsibilities:
* - Add, update, complete, and delete bucket list items
* - Observe items in real-time via Firestore snapshot listeners
* - Query items by category
*/
interface BucketListRepository {
// ─── CRUD methods ────────────────────────────────────────────────────────
/** Add a new bucket list item. */
suspend fun addItem(item: BucketListItem): String
/** Update an existing bucket list item. */
suspend fun updateItem(item: BucketListItem)
/** Get a bucket list item by ID. */
suspend fun getItem(itemId: String): BucketListItem?
/** Get all bucket list items for a couple. */
suspend fun getItems(coupleId: String): List<BucketListItem>
/** Delete a bucket list item. */
suspend fun deleteItem(itemId: String)
// ─── Completion methods ──────────────────────────────────────────────────
/** Mark an item as completed. */
suspend fun completeItem(itemId: String, completedBy: String)
// ─── Category methods ────────────────────────────────────────────────────
/** Get all items in a specific category. */
suspend fun getItemsByCategory(coupleId: String, category: String): List<BucketListItem>
// ─── Observation methods ─────────────────────────────────────────────────
/** Observe all bucket list items for a couple. */
fun observeItems(coupleId: String): Flow<List<BucketListItem>>
/** Observe items in a specific category for a couple. */
fun observeItemsByCategory(coupleId: String, category: String): Flow<List<BucketListItem>>
}

View File

@ -0,0 +1,56 @@
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.
*
* Responsibilities:
* - Store partner preferences locally (Room)
* - Assemble complete date plans from preferences
* - Sync plans to Firestore when shared/scheduled
*/
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<List<DatePlanPreference>>
// ─── Plan methods ────────────────────────────────────────────────────────
/** Get a date plan by ID. */
suspend fun getPlan(planId: String): DatePlan?
/** Get all plans for a couple. */
suspend fun getPlansForCouple(coupleId: String): List<DatePlan>
/** Get plans for a couple by status. */
suspend fun getPlansByStatus(coupleId: String, status: String): List<DatePlan>
/** Create or update a date plan. */
suspend fun savePlan(plan: DatePlan)
/** Update plan status (draft → planned → completed). */
suspend fun updatePlanStatus(planId: String, status: String)
/** 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?
}

View File

@ -0,0 +1,507 @@
package app.closer.ui.dates
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.graphics.vector.ImageVector
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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.domain.model.BucketListCategory
import app.closer.domain.model.BucketListItem
@Composable
fun BucketListScreen(
onNavigate: (String) -> Unit = {},
viewModel: BucketListViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
BucketListContent(
state = state,
onAddItem = { title, desc, cat -> viewModel.addItem(title, desc, cat) },
onToggleComplete = viewModel::toggleComplete,
onDelete = viewModel::deleteItem,
onSetFilter = viewModel::setCategoryFilter,
onBack = { onNavigate("back") }
)
}
@Composable
private fun BucketListContent(
state: BucketListUiState,
onAddItem: (String, String, String) -> Unit,
onToggleComplete: (String) -> Unit,
onDelete: (String) -> Unit,
onSetFilter: (String?) -> Unit,
onBack: () -> Unit
) {
var showAddDialog by remember { mutableStateOf(false) }
var newItemTitle by remember { mutableStateOf("") }
var newItemDescription by remember { mutableStateOf("") }
var newItemCategory by remember { mutableStateOf(BucketListCategory.ADVENTURE.toFirestoreValue()) }
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Header(onBack = onBack)
Spacer(modifier = Modifier.height(12.dp))
CategoryFilterChips(
categories = state.allCategories,
selected = state.categoryFilter,
onFilterChange = onSetFilter,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
BucketListItems(
items = state.filteredItems,
onToggleComplete = onToggleComplete,
onDelete = onDelete,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
}
FloatingActionButton(
onClick = { showAddDialog = true },
modifier = Modifier
.padding(20.dp)
.align(Alignment.BottomEnd),
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF271236)
) {
Text(text = "+", style = MaterialTheme.typography.titleLarge)
}
if (showAddDialog) {
AddItemDialog(
title = newItemTitle,
description = newItemDescription,
category = newItemCategory,
onTitleChange = { newItemTitle = it },
onDescriptionChange = { newItemDescription = it },
onCategoryChange = { newItemCategory = it },
onAdd = {
if (newItemTitle.isNotBlank()) {
onAddItem(newItemTitle, newItemDescription, newItemCategory)
showAddDialog = false
newItemTitle = ""
newItemDescription = ""
}
},
onDismiss = { showAddDialog = false }
)
}
}
}
@Composable
private fun Header(
onBack: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(top = 12.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Our Bucket List",
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
)
Text(
text = "Dream dates you both want to experience",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
)
}
}
}
@Composable
private fun CategoryFilterChips(
categories: List<String>,
selected: String?,
onFilterChange: (String?) -> Unit,
modifier: Modifier = Modifier
) {
if (categories.isEmpty()) return
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
label = "All",
selected = selected == null,
onClick = { onFilterChange(null) }
)
categories.forEach { category ->
FilterChip(
label = category.replaceFirstChar { it.uppercase() },
selected = selected == category,
onClick = { onFilterChange(category) }
)
}
}
}
@Composable
private fun FilterChip(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = if (selected) Color(0xFFB98AF4) else Color(0xFFFFF8FC),
tonalElevation = if (selected) 0.dp else 2.dp,
shadowElevation = if (selected) 0.dp else 2.dp,
modifier = Modifier.clickable(onClick = onClick)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = if (selected) Color(0xFF271236) else Color(0xFF5A5060),
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium
)
}
}
@Composable
private fun BucketListItems(
items: List<BucketListItem>,
onToggleComplete: (String) -> Unit,
onDelete: (String) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) {
EmptyState(
modifier = Modifier.padding(top = 80.dp)
)
return
}
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(items, key = { it.id }) { item ->
BucketListItemCard(
item = item,
onToggleComplete = onToggleComplete,
onDelete = onDelete
)
}
}
}
@Composable
private fun BucketListItemCard(
item: BucketListItem,
onToggleComplete: (String) -> Unit,
onDelete: (String) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.92f)),
elevation = CardDefaults.cardElevation(defaultElevation = 3.dp),
onClick = { onToggleComplete(item.id) }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CategoryBadge(category = item.category)
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = item.isCompleted,
onCheckedChange = { onToggleComplete(item.id) }
)
Text(
text = item.title,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
textDecoration = if (item.isCompleted) {
androidx.compose.ui.text.style.TextDecoration.LineThrough
} else {
androidx.compose.ui.text.style.TextDecoration.None
}
),
color = if (item.isCompleted) {
Color(0xFF5A5060).copy(alpha = 0.6f)
} else {
Color(0xFF261D2E)
}
)
}
TextButton(
onClick = { onDelete(item.id) },
modifier = Modifier.padding(start = 8.dp)
) {
Text("Delete", style = MaterialTheme.typography.labelSmall)
}
}
if (item.description.isNotBlank()) {
Text(
text = item.description,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
)
}
}
}
}
@Composable
private fun CategoryBadge(
category: String
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFF3E8FF)
) {
Text(
text = category.replaceFirstChar { it.uppercase() },
modifier = Modifier.padding(horizontal = 11.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF56306F),
fontWeight = FontWeight.SemiBold
)
}
}
@Composable
private fun EmptyState(
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFB98AF4).copy(alpha = 0.16f),
modifier = Modifier.size(64.dp)
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
tint = Color(0xFF56306F),
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No bucket list items yet",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF261D2E)
)
Text(
text = "Tap the + button to add your first dream date!",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
@Composable
private fun AddItemDialog(
title: String,
description: String,
category: String,
onTitleChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onCategoryChange: (String) -> Unit,
onAdd: () -> Unit,
onDismiss: () -> Unit
) {
val categories = BucketListCategory.values().map { it.toFirestoreValue() }
Surface(
modifier = Modifier.padding(20.dp),
shape = RoundedCornerShape(28.dp),
color = Color.White
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Add to Bucket List",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
)
OutlinedTextField(
value = title,
onValueChange = onTitleChange,
label = { Text("Title") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = description,
onValueChange = onDescriptionChange,
label = { Text("Description (optional)") },
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
categories.forEach { cat ->
val isSelected = category == cat
CategoryChip(
label = cat.replaceFirstChar { it.uppercase() },
selected = isSelected,
onClick = { onCategoryChange(cat) }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = onDismiss,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFE3D4EB)
)
) {
Text("Cancel", color = Color(0xFF5A5060))
}
Button(
onClick = onAdd,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF271236)
)
) {
Text("Add to List")
}
}
}
}
}
@Composable
private fun CategoryChip(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = if (selected) Color(0xFFB98AF4) else Color(0xFFFFF8FC),
tonalElevation = if (selected) 0.dp else 2.dp,
shadowElevation = if (selected) 0.dp else 2.dp,
modifier = Modifier.clickable(onClick = onClick)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelSmall,
color = if (selected) Color(0xFF271236) else Color(0xFF5A5060),
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium
)
}
}

View File

@ -0,0 +1,115 @@
package app.closer.ui.dates
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.model.BucketListItem
import app.closer.domain.repository.BucketListRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
class BucketListViewModel @Inject constructor(
private val repository: BucketListRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(BucketListUiState())
val uiState: StateFlow<BucketListUiState> = _uiState.asStateFlow()
fun setCoupleId(coupleId: String) {
_uiState.update { it.copy(coupleId = coupleId) }
loadItems()
}
fun loadItems() {
val coupleId = _uiState.value.coupleId
if (coupleId.isEmpty()) return
viewModelScope.launch {
val items = repository.getItems(coupleId)
_uiState.update { it.copy(items = items) }
}
}
fun addItem(
title: String,
description: String,
category: String
) {
val coupleId = _uiState.value.coupleId
if (coupleId.isEmpty()) return
val newItem = BucketListItem(
coupleId = coupleId,
title = title,
description = description,
category = category,
addedBy = "currentUser",
addedAt = System.currentTimeMillis()
)
viewModelScope.launch {
val itemId = repository.addItem(newItem)
val updatedItems = _uiState.value.items + newItem.copy(id = itemId)
_uiState.update { it.copy(items = updatedItems) }
}
}
fun toggleComplete(itemId: String) {
val item = _uiState.value.items.find { it.id == itemId } ?: return
viewModelScope.launch {
if (item.isCompleted) {
repository.updateItem(item.copy(isCompleted = false, completedBy = null, completedAt = null))
_uiState.update {
it.copy(
items = it.items.map { if (it.id == itemId) it.copy(isCompleted = false) else it }
)
}
} else {
repository.completeItem(itemId, "currentUser")
_uiState.update {
it.copy(
items = it.items.map {
if (it.id == itemId) it.copy(isCompleted = true, completedAt = System.currentTimeMillis())
else it
}
)
}
}
}
}
fun deleteItem(itemId: String) {
viewModelScope.launch {
repository.deleteItem(itemId)
_uiState.update {
it.copy(items = it.items.filter { it.id != itemId })
}
}
}
fun setCategoryFilter(category: String?) {
_uiState.update { it.copy(categoryFilter = category) }
}
fun clear() {
_uiState.update { BucketListUiState() }
}
}
data class BucketListUiState(
val coupleId: String = "",
val items: List<BucketListItem> = emptyList(),
val categoryFilter: String? = null
) {
val filteredItems: List<BucketListItem>
get() = if (categoryFilter == null) items else items.filter { it.category == categoryFilter }
val allCategories: List<String>
get() = items.map { it.category }.distinct()
}

View File

@ -0,0 +1,347 @@
package app.closer.ui.dates
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
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.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun DateBuilderScreen(
onNavigate: (String) -> Unit = {},
viewModel: DateBuilderViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
DateBuilderContent(
state = state,
onDateChange = viewModel::updateDate,
onTimeChange = viewModel::updateTime,
onBudgetChange = viewModel::updateBudget,
onDurationChange = viewModel::updateDuration,
onSave = { viewModel.savePreference() },
onBack = { onNavigate("back") }
)
}
@Composable
private fun DateBuilderContent(
state: DateBuilderUiState,
onDateChange: (Long) -> Unit,
onTimeChange: (String) -> Unit,
onBudgetChange: (Int) -> Unit,
onDurationChange: (String) -> Unit,
onSave: () -> Unit,
onBack: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Header(onBack = onBack)
Spacer(modifier = Modifier.height(24.dp))
InputCard(
state = state,
onDateChange = onDateChange,
onTimeChange = onTimeChange,
onBudgetChange = onBudgetChange,
onDurationChange = onDurationChange
)
Spacer(modifier = Modifier.height(24.dp))
SaveButton(
onSave = onSave,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
)
}
}
}
@Composable
private fun Header(
onBack: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(top = 12.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Plan a Date",
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
)
Text(
text = "Tell us what you're looking for",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
)
}
}
@Composable
private fun InputCard(
state: DateBuilderUiState,
onDateChange: (Long) -> Unit,
onTimeChange: (String) -> Unit,
onBudgetChange: (Int) -> Unit,
onDurationChange: (String) -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.86f),
tonalElevation = 0.dp,
shadowElevation = 2.dp
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
// Date selection
DateTimeField(
label = "When?",
value = state.scheduledDate.takeIf { it > 0 }?.let {
java.text.SimpleDateFormat("MMMM dd, yyyy", java.util.Locale.getDefault()).format(java.util.Date(it))
} ?: "Select a date",
onClick = { /* TODO: Date picker dialog */ },
modifier = Modifier.fillMaxWidth()
)
// Time selection
DateTimeField(
label = "What time?",
value = state.scheduledTime.ifEmpty { "Select a time" },
onClick = { /* TODO: Time picker dialog */ },
modifier = Modifier.fillMaxWidth()
)
// Budget slider/field
BudgetField(
budget = state.budget,
onBudgetChange = onBudgetChange,
modifier = Modifier.fillMaxWidth()
)
// Duration selector
DurationSelector(
selected = state.duration,
onDurationChange = onDurationChange
)
}
}
}
@Composable
private fun DateTimeField(
label: String,
value: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.clickable(onClick = onClick)
.background(Color(0xFFFFF8FC), RoundedCornerShape(16.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060),
fontWeight = FontWeight.Medium
)
Text(
text = value,
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF261D2E),
fontWeight = FontWeight.SemiBold
)
}
}
@Composable
private fun BudgetField(
budget: Int,
onBudgetChange: (Int) -> Unit,
modifier: Modifier = Modifier
) {
var textValue by remember { mutableStateOf(budget.toString()) }
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Budget",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060),
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = textValue,
onValueChange = {
textValue = it.filter { it.isDigit() }
onBudgetChange(it.toIntOrNull() ?: 0)
},
label = { Text("$") },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp)
)
Text(
text = "$budget",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF56306F),
fontWeight = FontWeight.SemiBold
)
}
}
}
@Composable
private fun DurationSelector(
selected: String,
onDurationChange: (String) -> Unit
) {
val durations = listOf("1-2 hours", "Half day", "Full day")
var selectedDuration by remember { mutableStateOf(selected) }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Duration",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060),
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
durations.forEach { duration ->
val isSelected = selectedDuration == duration
DurationChip(
label = duration,
selected = isSelected,
onClick = {
selectedDuration = duration
onDurationChange(duration)
}
)
}
}
}
}
@Composable
private fun DurationChip(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = if (selected) Color(0xFFB98AF4) else Color(0xFFFFF8FC),
tonalElevation = if (selected) 0.dp else 2.dp,
shadowElevation = if (selected) 0.dp else 2.dp,
modifier = Modifier.clickable(onClick = onClick)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = if (selected) Color(0xFF271236) else Color(0xFF5A5060),
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium
)
}
}
@Composable
private fun SaveButton(
onSave: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onSave,
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF271236)
)
) {
Text("Create Plan")
}
}

View File

@ -0,0 +1,71 @@
package app.closer.ui.dates
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.model.DatePlanPreference
import app.closer.domain.repository.DatePlanRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
class DateBuilderViewModel @Inject constructor(
private val repository: DatePlanRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(DateBuilderUiState())
val uiState: StateFlow<DateBuilderUiState> = _uiState.asStateFlow()
fun updateDate(date: Long) {
_uiState.update { it.copy(scheduledDate = date) }
}
fun updateTime(time: String) {
_uiState.update { it.copy(scheduledTime = time) }
}
fun updateBudget(budget: Int) {
_uiState.update { it.copy(budget = budget) }
}
fun updateDuration(duration: String) {
_uiState.update { it.copy(duration = duration) }
}
fun setDateIdeaId(dateIdeaId: String) {
_uiState.update { it.copy(dateIdeaId = dateIdeaId) }
}
fun savePreference() {
val state = _uiState.value
if (state.dateIdeaId.isEmpty()) return
val preference = DatePlanPreference(
dateIdeaId = state.dateIdeaId,
preferredDate = state.scheduledDate,
preferredTime = state.scheduledTime,
budget = state.budget,
duration = state.duration
)
viewModelScope.launch {
repository.savePreference(preference)
}
}
fun clear() {
_uiState.update { DateBuilderUiState() }
}
}
data class DateBuilderUiState(
val dateIdeaId: String = "",
val scheduledDate: Long = 0L,
val scheduledTime: String = "",
val budget: Int = 0,
val duration: String = ""
)

View File

@ -264,6 +264,33 @@ service cloud.firestore {
allow read: if isCouplesMember(coupleId);
allow create, update, delete: if false;
}
// Date plan preferences: per-partner preferences for building date plans.
// Both members can read; each member can write their own preference.
match /date_plan_preferences/{userId} {
allow read: if isCouplesMember(coupleId);
allow create, update: if isCouplesMember(coupleId)
&& request.resource.data.keys().hasOnly(['actions'])
&& request.resource.data.actions.keys().hasOnly([request.auth.uid])
&& request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt'])
&& request.resource.data.actions[request.auth.uid].action != null
&& request.resource.data.actions[request.auth.uid].swipedAt is timestamp;
allow delete: if false;
}
// Date plans: complete plans assembled from partner preferences.
// Both members can read; create/update/delete by both members.
match /date_plans/{planId} {
allow read: if isCouplesMember(coupleId);
allow create, update, delete: if isCouplesMember(coupleId);
}
// Bucket list items: shared list for both partners.
// Both members can read; create/update/delete by both members.
match /bucket_list/{itemId} {
allow read: if isCouplesMember(coupleId);
allow create, update, delete: if isCouplesMember(coupleId);
}
}
}
}