diff --git a/app/schemas/app.closer.data.local.AppDatabase/1.json b/app/schemas/app.closer.data.local.AppDatabase/1.json index 3431bb1d..1e872f51 100644 --- a/app/schemas/app.closer.data.local.AppDatabase/1.json +++ b/app/schemas/app.closer.data.local.AppDatabase/1.json @@ -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')" ] } } \ No newline at end of file diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index cd1fc94a..65a15564 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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) { diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index 1b4a0c79..b9bacf79 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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, 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 eafd816a..c27428fd 100644 --- a/app/src/main/java/app/closer/data/local/AppDatabase.kt +++ b/app/src/main/java/app/closer/data/local/AppDatabase.kt @@ -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 } diff --git a/app/src/main/java/app/closer/data/local/DatePlanDao.kt b/app/src/main/java/app/closer/data/local/DatePlanDao.kt new file mode 100644 index 00000000..6de90b3e --- /dev/null +++ b/app/src/main/java/app/closer/data/local/DatePlanDao.kt @@ -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 + + @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 + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(plan: DatePlanEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(plans: List) + + @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()) +} diff --git a/app/src/main/java/app/closer/data/local/DatePlanPreferenceDao.kt b/app/src/main/java/app/closer/data/local/DatePlanPreferenceDao.kt new file mode 100644 index 00000000..5cca98ef --- /dev/null +++ b/app/src/main/java/app/closer/data/local/DatePlanPreferenceDao.kt @@ -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 + + @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/DatePlanEntity.kt b/app/src/main/java/app/closer/data/local/entity/DatePlanEntity.kt new file mode 100644 index 00000000..68b93b4d --- /dev/null +++ b/app/src/main/java/app/closer/data/local/entity/DatePlanEntity.kt @@ -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 +) 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 new file mode 100644 index 00000000..be9a7f73 --- /dev/null +++ b/app/src/main/java/app/closer/data/local/entity/DatePlanPreferenceEntity.kt @@ -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 +) diff --git a/app/src/main/java/app/closer/data/remote/FirestoreBucketListDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreBucketListDataSource.kt new file mode 100644 index 00000000..dd6107b4 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreBucketListDataSource.kt @@ -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> { + 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 { + 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> { + 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 { + 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.voidAwait() = + suspendCancellableCoroutine { 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 + ) + } +} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDatePlanDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDatePlanDataSource.kt new file mode 100644 index 00000000..8c97b288 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreDatePlanDataSource.kt @@ -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> { + 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> { + 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.voidAwait() = + suspendCancellableCoroutine { 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) ?: emptyList(), + optionalChallenge = getString("optionalChallenge"), + createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L, + updatedAt = (get("updatedAt") as? Number)?.toLong() ?: 0L + ) + } +} diff --git a/app/src/main/java/app/closer/data/repository/BucketListRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/BucketListRepositoryImpl.kt new file mode 100644 index 00000000..bb86b67b --- /dev/null +++ b/app/src/main/java/app/closer/data/repository/BucketListRepositoryImpl.kt @@ -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 { + 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 { + return dataSource.getItemsByCategory(coupleId, category) + } + + // ─── Observation methods ───────────────────────────────────────────────── + + override fun observeItems(coupleId: String): Flow> { + return dataSource.observeItems(coupleId) + } + + override fun observeItemsByCategory(coupleId: String, category: String): Flow> { + return dataSource.observeItemsByCategory(coupleId, category) + } +} diff --git a/app/src/main/java/app/closer/data/repository/DatePlanRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/DatePlanRepositoryImpl.kt new file mode 100644 index 00000000..94a97c3f --- /dev/null +++ b/app/src/main/java/app/closer/data/repository/DatePlanRepositoryImpl.kt @@ -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> { + 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 { + // Get from local Room database + return planDao.getByCoupleId(coupleId).map { it.toDomain() } + } + + override suspend fun getPlansByStatus(coupleId: String, status: String): List { + // 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 + ) +} diff --git a/app/src/main/java/app/closer/di/RepositoryModule.kt b/app/src/main/java/app/closer/di/RepositoryModule.kt index a127a93a..76dc6e97 100644 --- a/app/src/main/java/app/closer/di/RepositoryModule.kt +++ b/app/src/main/java/app/closer/di/RepositoryModule.kt @@ -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 diff --git a/app/src/main/java/app/closer/domain/model/BucketListItem.kt b/app/src/main/java/app/closer/domain/model/BucketListItem.kt new file mode 100644 index 00000000..831d15be --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/BucketListItem.kt @@ -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 + } + } +} diff --git a/app/src/main/java/app/closer/domain/model/DatePlan.kt b/app/src/main/java/app/closer/domain/model/DatePlan.kt new file mode 100644 index 00000000..bfc7c5c2 --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/DatePlan.kt @@ -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., "2–3 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 = 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 + } + } +} diff --git a/app/src/main/java/app/closer/domain/model/DatePlanPreference.kt b/app/src/main/java/app/closer/domain/model/DatePlanPreference.kt new file mode 100644 index 00000000..3912afe5 --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/DatePlanPreference.kt @@ -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 +) diff --git a/app/src/main/java/app/closer/domain/model/DatePlanSuggestion.kt b/app/src/main/java/app/closer/domain/model/DatePlanSuggestion.kt new file mode 100644 index 00000000..139a72a0 --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/DatePlanSuggestion.kt @@ -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 = emptyList(), + val optionalChallenge: String? = null +) diff --git a/app/src/main/java/app/closer/domain/repository/BucketListRepository.kt b/app/src/main/java/app/closer/domain/repository/BucketListRepository.kt new file mode 100644 index 00000000..fc765c02 --- /dev/null +++ b/app/src/main/java/app/closer/domain/repository/BucketListRepository.kt @@ -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 + + /** 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 + + // ─── Observation methods ───────────────────────────────────────────────── + + /** Observe all bucket list items for a couple. */ + fun observeItems(coupleId: String): Flow> + + /** Observe items in a specific category for a couple. */ + fun observeItemsByCategory(coupleId: String, category: String): Flow> +} diff --git a/app/src/main/java/app/closer/domain/repository/DatePlanRepository.kt b/app/src/main/java/app/closer/domain/repository/DatePlanRepository.kt new file mode 100644 index 00000000..c941afab --- /dev/null +++ b/app/src/main/java/app/closer/domain/repository/DatePlanRepository.kt @@ -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> + + // ─── 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 + + /** Get plans for a couple by status. */ + suspend fun getPlansByStatus(coupleId: String, status: String): List + + /** 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? +} diff --git a/app/src/main/java/app/closer/ui/dates/BucketListScreen.kt b/app/src/main/java/app/closer/ui/dates/BucketListScreen.kt new file mode 100644 index 00000000..b4be8fb4 --- /dev/null +++ b/app/src/main/java/app/closer/ui/dates/BucketListScreen.kt @@ -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, + 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, + 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 + ) + } +} diff --git a/app/src/main/java/app/closer/ui/dates/BucketListViewModel.kt b/app/src/main/java/app/closer/ui/dates/BucketListViewModel.kt new file mode 100644 index 00000000..f66bc085 --- /dev/null +++ b/app/src/main/java/app/closer/ui/dates/BucketListViewModel.kt @@ -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 = _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 = emptyList(), + val categoryFilter: String? = null +) { + val filteredItems: List + get() = if (categoryFilter == null) items else items.filter { it.category == categoryFilter } + + val allCategories: List + get() = items.map { it.category }.distinct() +} diff --git a/app/src/main/java/app/closer/ui/dates/DateBuilderScreen.kt b/app/src/main/java/app/closer/ui/dates/DateBuilderScreen.kt new file mode 100644 index 00000000..48a8be56 --- /dev/null +++ b/app/src/main/java/app/closer/ui/dates/DateBuilderScreen.kt @@ -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") + } +} diff --git a/app/src/main/java/app/closer/ui/dates/DateBuilderViewModel.kt b/app/src/main/java/app/closer/ui/dates/DateBuilderViewModel.kt new file mode 100644 index 00000000..1b7aed90 --- /dev/null +++ b/app/src/main/java/app/closer/ui/dates/DateBuilderViewModel.kt @@ -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 = _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 = "" +) diff --git a/firestore.rules b/firestore.rules index 841f653c..29324d1c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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); + } } } }