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:
parent
512a6c9f42
commit
557af3e546
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = ""
|
||||
)
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue