feat(dates): add Date Match MVP Phase 1 — swipe UI, Firestore models, 30+ seed ideas, match reveal
This commit is contained in:
parent
1fc25d6c1f
commit
512a6c9f42
|
|
@ -42,6 +42,8 @@ import app.closer.ui.pairing.AcceptInviteScreen
|
||||||
import app.closer.ui.pairing.CreateInviteScreen
|
import app.closer.ui.pairing.CreateInviteScreen
|
||||||
import app.closer.ui.pairing.EmailInviteScreen
|
import app.closer.ui.pairing.EmailInviteScreen
|
||||||
import app.closer.ui.pairing.InviteConfirmScreen
|
import app.closer.ui.pairing.InviteConfirmScreen
|
||||||
|
import app.closer.ui.dates.DateMatchScreen
|
||||||
|
import app.closer.ui.dates.DateMatchesScreen
|
||||||
import app.closer.ui.paywall.PaywallScreen
|
import app.closer.ui.paywall.PaywallScreen
|
||||||
import app.closer.ui.questions.DailyQuestionScreen
|
import app.closer.ui.questions.DailyQuestionScreen
|
||||||
import app.closer.ui.questions.QuestionCategoryScreen
|
import app.closer.ui.questions.QuestionCategoryScreen
|
||||||
|
|
@ -287,6 +289,14 @@ fun AppNavigation(
|
||||||
WheelHistoryScreen(onNavigate = navigateRoute)
|
WheelHistoryScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
composable(route = AppRoute.DATE_MATCH) {
|
||||||
|
DateMatchScreen(onNavigate = navigateRoute)
|
||||||
|
}
|
||||||
|
composable(route = AppRoute.DATE_MATCHES) {
|
||||||
|
DateMatchesScreen(onNavigate = navigateRoute)
|
||||||
|
}
|
||||||
|
|
||||||
// Paywall
|
// Paywall
|
||||||
composable(route = AppRoute.PAYWALL) {
|
composable(route = AppRoute.PAYWALL) {
|
||||||
PaywallScreen(onNavigate = navigateRoute)
|
PaywallScreen(onNavigate = navigateRoute)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ object AppRoute {
|
||||||
const val RELATIONSHIP_SETTINGS = "relationship_settings"
|
const val RELATIONSHIP_SETTINGS = "relationship_settings"
|
||||||
const val DELETE_ACCOUNT = "delete_account"
|
const val DELETE_ACCOUNT = "delete_account"
|
||||||
const val WHEEL_HISTORY = "wheel_history"
|
const val WHEEL_HISTORY = "wheel_history"
|
||||||
|
const val DATE_MATCH = "date_match"
|
||||||
|
const val DATE_MATCHES = "date_matches"
|
||||||
|
|
||||||
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
||||||
const val QUESTION_THREAD =
|
const val QUESTION_THREAD =
|
||||||
|
|
@ -75,7 +77,9 @@ object AppRoute {
|
||||||
Definition(SUBSCRIPTION, "Subscription", "settings"),
|
Definition(SUBSCRIPTION, "Subscription", "settings"),
|
||||||
Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "settings"),
|
Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "settings"),
|
||||||
Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
|
Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
|
||||||
Definition(WHEEL_HISTORY, "Wheel History", "wheel")
|
Definition(WHEEL_HISTORY, "Wheel History", "wheel"),
|
||||||
|
Definition(DATE_MATCH, "Date Match", "dates"),
|
||||||
|
Definition(DATE_MATCHES, "Matches", "dates")
|
||||||
)
|
)
|
||||||
|
|
||||||
val topLevelRoutes = setOf(
|
val topLevelRoutes = setOf(
|
||||||
|
|
@ -113,6 +117,8 @@ object AppRoute {
|
||||||
WHEEL_SESSION,
|
WHEEL_SESSION,
|
||||||
WHEEL_COMPLETE,
|
WHEEL_COMPLETE,
|
||||||
WHEEL_HISTORY,
|
WHEEL_HISTORY,
|
||||||
|
DATE_MATCH,
|
||||||
|
DATE_MATCHES,
|
||||||
ACCOUNT,
|
ACCOUNT,
|
||||||
NOTIFICATIONS,
|
NOTIFICATIONS,
|
||||||
PRIVACY,
|
PRIVACY,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.domain.model.DateIdea
|
||||||
|
import app.closer.domain.model.DateMatch
|
||||||
|
import com.google.firebase.firestore.DocumentSnapshot
|
||||||
|
import com.google.firebase.firestore.FieldValue
|
||||||
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
|
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 revealed mutual date matches.
|
||||||
|
*
|
||||||
|
* Path layout:
|
||||||
|
* couples/{coupleId}/date_matches/{matchId}
|
||||||
|
*
|
||||||
|
* Each document contains:
|
||||||
|
* {
|
||||||
|
* "dateIdeaId": "idea_123",
|
||||||
|
* "revealedAt": 12345,
|
||||||
|
* "matchedBy": ["userA", "userB"]
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Security rules deny client create/update/delete; matches are written by a
|
||||||
|
* Cloud Function after both partners swipe [SwipeAction.LOVE]. The client layer
|
||||||
|
* still exposes a create helper for the function trigger path.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FirestoreDateMatchDataSource @Inject constructor() {
|
||||||
|
|
||||||
|
private val db = FirebaseFirestore.getInstance()
|
||||||
|
private fun matchesRef(coupleId: String) =
|
||||||
|
db.collection("couples").document(coupleId).collection("date_matches")
|
||||||
|
|
||||||
|
suspend fun createMatch(coupleId: String, dateIdeaId: String, matchedBy: List<String>): String {
|
||||||
|
val doc = matchesRef(coupleId).document()
|
||||||
|
doc.set(
|
||||||
|
mapOf(
|
||||||
|
"dateIdeaId" to dateIdeaId,
|
||||||
|
"revealedAt" to FieldValue.serverTimestamp(),
|
||||||
|
"matchedBy" to matchedBy
|
||||||
|
)
|
||||||
|
).voidAwait()
|
||||||
|
return doc.id
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findMatchByDateIdeaId(coupleId: String, dateIdeaId: String): DateMatch? {
|
||||||
|
val snap = matchesRef(coupleId)
|
||||||
|
.whereEqualTo("dateIdeaId", dateIdeaId)
|
||||||
|
.limit(1)
|
||||||
|
.queryAwait()
|
||||||
|
return snap.documents.firstOrNull()?.toDateMatch(coupleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeMatches(coupleId: String): Flow<List<DateMatch>> = callbackFlow {
|
||||||
|
val listener = matchesRef(coupleId)
|
||||||
|
.orderBy("revealedAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
|
||||||
|
.addSnapshotListener { snap, err ->
|
||||||
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
|
trySend(snap.documents.mapNotNull { it.toDateMatch(coupleId) })
|
||||||
|
}
|
||||||
|
awaitClose { listener.remove() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mapper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun DocumentSnapshot.toDateMatch(coupleId: String): DateMatch? {
|
||||||
|
val dateIdeaId = getString("dateIdeaId") ?: return null
|
||||||
|
return DateMatch(
|
||||||
|
id = id,
|
||||||
|
coupleId = coupleId,
|
||||||
|
dateIdeaId = dateIdeaId,
|
||||||
|
revealedAt = getTimestamp("revealedAt")?.toDate()?.time ?: 0L,
|
||||||
|
matchedBy = (get("matchedBy") as? List<String>) ?: emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.domain.model.DateSwipe
|
||||||
|
import app.closer.domain.model.SwipeAction
|
||||||
|
import com.google.firebase.firestore.DocumentSnapshot
|
||||||
|
import com.google.firebase.firestore.FieldValue
|
||||||
|
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 per-couple, per-date swipe state.
|
||||||
|
*
|
||||||
|
* Path layout:
|
||||||
|
* couples/{coupleId}/date_swipes/{dateId}
|
||||||
|
*
|
||||||
|
* Each document stores a map keyed by userId:
|
||||||
|
* {
|
||||||
|
* "actions": {
|
||||||
|
* "userA": { "action": "love", "swipedAt": 12345 },
|
||||||
|
* "userB": { "action": "skip", "swipedAt": 12346 }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Security rules ensure only couple members can read/write, and each user can
|
||||||
|
* only write their own action entry.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FirestoreDateSwipeDataSource @Inject constructor() {
|
||||||
|
|
||||||
|
private val db = FirebaseFirestore.getInstance()
|
||||||
|
private fun swipesRef(coupleId: String) =
|
||||||
|
db.collection("couples").document(coupleId).collection("date_swipes")
|
||||||
|
|
||||||
|
suspend fun recordSwipe(coupleId: String, swipe: DateSwipe) {
|
||||||
|
val path = swipesRef(coupleId).document(swipe.dateIdeaId)
|
||||||
|
val entry = mapOf(
|
||||||
|
"action" to swipe.action.toFirestoreValue(),
|
||||||
|
"swipedAt" to swipe.swipedAt
|
||||||
|
)
|
||||||
|
path.set(
|
||||||
|
mapOf("actions" to mapOf(swipe.userId to entry)),
|
||||||
|
SetOptions.merge()
|
||||||
|
).voidAwait()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getSwipe(coupleId: String, dateIdeaId: String, userId: String): DateSwipe? {
|
||||||
|
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
|
||||||
|
return snap.toDateSwipe(dateIdeaId, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeOwnSwipes(
|
||||||
|
coupleId: String,
|
||||||
|
userId: String,
|
||||||
|
once: Boolean = false
|
||||||
|
): Flow<List<DateSwipe>> {
|
||||||
|
return if (once) {
|
||||||
|
kotlinx.coroutines.flow.flow {
|
||||||
|
val swipes = getAllSwipesForUser(coupleId, userId)
|
||||||
|
emit(swipes)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callbackFlow {
|
||||||
|
val listener = swipesRef(coupleId)
|
||||||
|
.addSnapshotListener { snap, err ->
|
||||||
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
|
val swipes = snap.documents.mapNotNull { it.toDateSwipe(it.id, userId) }
|
||||||
|
trySend(swipes)
|
||||||
|
}
|
||||||
|
awaitClose { listener.remove() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private suspend fun getAllSwipesForUser(coupleId: String, userId: String): List<DateSwipe> {
|
||||||
|
val snap = swipesRef(coupleId).getQuery()
|
||||||
|
return snap.documents.mapNotNull { it.toDateSwipe(it.id, userId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun com.google.firebase.firestore.CollectionReference.getQuery() =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
get()
|
||||||
|
.addOnSuccessListener { cont.resume(it) }
|
||||||
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAllSwipesForDate(coupleId: String, dateIdeaId: String): List<DateSwipe> {
|
||||||
|
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val actions = snap.get("actions") as? Map<String, Map<String, Any>> ?: emptyMap()
|
||||||
|
return actions.map { (uid, data) ->
|
||||||
|
DateSwipe(
|
||||||
|
dateIdeaId = dateIdeaId,
|
||||||
|
userId = uid,
|
||||||
|
action = SwipeAction.fromFirestoreValue(data["action"] as? String ?: ""),
|
||||||
|
swipedAt = (data["swipedAt"] as? Number)?.toLong() ?: 0L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Coroutine helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun com.google.firebase.firestore.DocumentReference.getDoc(): DocumentSnapshot =
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mappers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun DocumentSnapshot.toDateSwipe(dateIdeaId: String, userId: String): DateSwipe? {
|
||||||
|
val actions = get("actions") as? Map<String, Map<String, Any>> ?: return null
|
||||||
|
val data = actions[userId] ?: return null
|
||||||
|
return DateSwipe(
|
||||||
|
dateIdeaId = dateIdeaId,
|
||||||
|
userId = userId,
|
||||||
|
action = SwipeAction.fromFirestoreValue(data["action"] as? String ?: ""),
|
||||||
|
swipedAt = (data["swipedAt"] as? Number)?.toLong() ?: 0L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
package app.closer.data.repository
|
||||||
|
|
||||||
|
import app.closer.domain.model.DateCostLevel
|
||||||
|
import app.closer.domain.model.DateIdea
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed catalog of curated date ideas for the Date Match MVP.
|
||||||
|
*
|
||||||
|
* 30+ ideas across Adventure, Travel, Food, Learning, Romance, Intimacy, and
|
||||||
|
* Seasonal categories. A subset are premium (isPremium = true). The same seed
|
||||||
|
* is also written to the Firestore `date_ideas` collection via server-side seed
|
||||||
|
* function; the app ships an in-memory copy so ideas are immediately available
|
||||||
|
* offline.
|
||||||
|
*/
|
||||||
|
object DateIdeaSeed {
|
||||||
|
|
||||||
|
val all: List<DateIdea> = listOf(
|
||||||
|
// Adventure
|
||||||
|
DateIdea(
|
||||||
|
id = "adventure_sunrise_hike",
|
||||||
|
title = "Sunrise hike + thermos coffee",
|
||||||
|
description = "Pick a nearby overlook, pack warm drinks, and watch the sky change together.",
|
||||||
|
category = "adventure",
|
||||||
|
estimatedDuration = "2–3 hours",
|
||||||
|
estimatedCost = DateCostLevel.FREE,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "adventure_kayak",
|
||||||
|
title = "Kayak or canoe rental",
|
||||||
|
description = "Spend a quiet morning paddling a local lake or river.",
|
||||||
|
category = "adventure",
|
||||||
|
estimatedDuration = "Half day",
|
||||||
|
estimatedCost = DateCostLevel.MEDIUM,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "adventure_rock_climbing",
|
||||||
|
title = "Indoor rock climbing",
|
||||||
|
description = "Try a beginner climbing gym session and cheer each other up the wall.",
|
||||||
|
category = "adventure",
|
||||||
|
estimatedDuration = "2 hours",
|
||||||
|
estimatedCost = DateCostLevel.MEDIUM,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "adventure_overnight_camping",
|
||||||
|
title = "Overnight camping getaway",
|
||||||
|
description = "Pack a tent and disappear for one night somewhere with dark skies.",
|
||||||
|
category = "adventure",
|
||||||
|
estimatedDuration = "1–2 days",
|
||||||
|
estimatedCost = DateCostLevel.MEDIUM,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "adventure_zipline",
|
||||||
|
title = "Zipline canopy tour",
|
||||||
|
description = "Fly through the trees on a guided zip-line course.",
|
||||||
|
category = "adventure",
|
||||||
|
estimatedDuration = "Half day",
|
||||||
|
estimatedCost = DateCostLevel.HIGH,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
|
||||||
|
// Travel
|
||||||
|
DateIdea(
|
||||||
|
id = "travel_day_town",
|
||||||
|
title = "Day trip to a nearby town",
|
||||||
|
description = "Drive somewhere neither of you has explored and wander without an itinerary.",
|
||||||
|
category = "travel",
|
||||||
|
estimatedDuration = "Full day",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "travel_scenic_drive",
|
||||||
|
title = "Scenic drive with a playlist",
|
||||||
|
description = "Build a shared playlist, pick a pretty route, and stop for snacks.",
|
||||||
|
category = "travel",
|
||||||
|
estimatedDuration = "3–4 hours",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "travel_weekend_escape",
|
||||||
|
title = "Surprise weekend escape",
|
||||||
|
description = "One partner plans the destination; the other packs a bag blind.",
|
||||||
|
category = "travel",
|
||||||
|
estimatedDuration = "2 days",
|
||||||
|
estimatedCost = DateCostLevel.HIGH,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "travel_roadside_attractions",
|
||||||
|
title = "Roadside attractions tour",
|
||||||
|
description = "Find the weirdest small attractions within a two-hour radius and visit three.",
|
||||||
|
category = "travel",
|
||||||
|
estimatedDuration = "Full day",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
|
||||||
|
// Food
|
||||||
|
DateIdea(
|
||||||
|
id = "food_farmers_market",
|
||||||
|
title = "Farmers market breakfast",
|
||||||
|
description = "Buy fresh pastries and fruit, then eat somewhere people-watching is easy.",
|
||||||
|
category = "food",
|
||||||
|
estimatedDuration = "2 hours",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "food_cook_together",
|
||||||
|
title = "Cook a new cuisine together",
|
||||||
|
description = "Pick a recipe neither of you has made, shop together, and make a mess.",
|
||||||
|
category = "food",
|
||||||
|
estimatedDuration = "3 hours",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "food_food_truck_crawl",
|
||||||
|
title = "Food truck crawl",
|
||||||
|
description = "Share three different dishes from three different trucks.",
|
||||||
|
category = "food",
|
||||||
|
estimatedDuration = "2 hours",
|
||||||
|
estimatedCost = DateCostLevel.MEDIUM,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "food_tasting_menu",
|
||||||
|
title = "Tasting menu dinner",
|
||||||
|
description = "Book a special restaurant and make an evening of every course.",
|
||||||
|
category = "food",
|
||||||
|
estimatedDuration = "3 hours",
|
||||||
|
estimatedCost = DateCostLevel.HIGH,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "food_winery_tour",
|
||||||
|
title = "Winery or brewery tour",
|
||||||
|
description = "Taste flights, learn something, and bring home a bottle to share later.",
|
||||||
|
category = "food",
|
||||||
|
estimatedDuration = "Half day",
|
||||||
|
estimatedCost = DateCostLevel.HIGH,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "food_pizza_night",
|
||||||
|
title = "Homemade pizza night",
|
||||||
|
description = "Make dough from scratch and compete for the most ridiculous topping combo.",
|
||||||
|
category = "food",
|
||||||
|
estimatedDuration = "2–3 hours",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
|
||||||
|
// Learning
|
||||||
|
DateIdea(
|
||||||
|
id = "learning_museum",
|
||||||
|
title = "Museum or gallery visit",
|
||||||
|
description = "Walk slowly through an exhibit and share the one piece that moved you most.",
|
||||||
|
category = "learning",
|
||||||
|
estimatedDuration = "2–3 hours",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "learning_dance_class",
|
||||||
|
title = "Beginner dance class",
|
||||||
|
description = "Try salsa, swing, or ballroom — no experience required.",
|
||||||
|
category = "learning",
|
||||||
|
estimatedDuration = "1.5 hours",
|
||||||
|
estimatedCost = DateCostLevel.MEDIUM,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "learning_workshop",
|
||||||
|
title = "Creative workshop",
|
||||||
|
description = "Pottery, painting, woodworking — make something tangible together.",
|
||||||
|
category = "learning",
|
||||||
|
estimatedDuration = "2–3 hours",
|
||||||
|
estimatedCost = DateCostLevel.MEDIUM,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "learning_language",
|
||||||
|
title = "Learn a language lesson together",
|
||||||
|
description = "Pick a travel destination and learn ten phrases as a team.",
|
||||||
|
category = "learning",
|
||||||
|
estimatedDuration = "1 hour",
|
||||||
|
estimatedCost = DateCostLevel.FREE,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "learning_lecture",
|
||||||
|
title = "Attend a lecture or talk",
|
||||||
|
description = "Find a local speaker on a topic you both know nothing about.",
|
||||||
|
category = "learning",
|
||||||
|
estimatedDuration = "2 hours",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
|
||||||
|
// Romance
|
||||||
|
DateIdea(
|
||||||
|
id = "romance_sunset_picnic",
|
||||||
|
title = "Sunset picnic",
|
||||||
|
description = "Pack a blanket, simple snacks, and watch the sun go down somewhere quiet.",
|
||||||
|
category = "romance",
|
||||||
|
estimatedDuration = "2 hours",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "romance_stargazing",
|
||||||
|
title = "Stargazing with blankets",
|
||||||
|
description = "Drive away from city lights, lie back, and name constellations.",
|
||||||
|
category = "romance",
|
||||||
|
estimatedDuration = "2 hours",
|
||||||
|
estimatedCost = DateCostLevel.FREE,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "romance_couples_massage",
|
||||||
|
title = "Couples massage",
|
||||||
|
description = "Book side-by-side treatments and unwind together.",
|
||||||
|
category = "romance",
|
||||||
|
estimatedDuration = "2 hours",
|
||||||
|
estimatedCost = DateCostLevel.HIGH,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "romance_sunset_sail",
|
||||||
|
title = "Sunset sail or boat cruise",
|
||||||
|
description = "Catch the golden hour from the water with a drink in hand.",
|
||||||
|
category = "romance",
|
||||||
|
estimatedDuration = "2–3 hours",
|
||||||
|
estimatedCost = DateCostLevel.HIGH,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "romance_home_spa",
|
||||||
|
title = "At-home spa night",
|
||||||
|
description = "Face masks, candles, soft music, and zero pressure to do anything else.",
|
||||||
|
category = "romance",
|
||||||
|
estimatedDuration = "2 hours",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
|
||||||
|
// Intimacy
|
||||||
|
DateIdea(
|
||||||
|
id = "intimacy_question_deck",
|
||||||
|
title = "Deeper question card night",
|
||||||
|
description = "Use a deck of intimacy prompts and answer them honestly over wine or tea.",
|
||||||
|
category = "intimacy",
|
||||||
|
estimatedDuration = "1.5 hours",
|
||||||
|
estimatedCost = DateCostLevel.FREE,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "intimacy_slow_cook",
|
||||||
|
title = "Slow-cook dinner with no phones",
|
||||||
|
description = "Make a meal that takes real time and spend every minute talking.",
|
||||||
|
category = "intimacy",
|
||||||
|
estimatedDuration = "3–4 hours",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "intimacy_letters",
|
||||||
|
title = "Write each other letters",
|
||||||
|
description = "Handwrite what you appreciate about each other, seal them, then read aloud.",
|
||||||
|
category = "intimacy",
|
||||||
|
estimatedDuration = "1 hour",
|
||||||
|
estimatedCost = DateCostLevel.FREE,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "intimacy_trust_exercise",
|
||||||
|
title = "Guided trust exercise",
|
||||||
|
description = "Follow a short couples communication exercise with eye contact and listening.",
|
||||||
|
category = "intimacy",
|
||||||
|
estimatedDuration = "1 hour",
|
||||||
|
estimatedCost = DateCostLevel.FREE,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "intimacy_walk_memory_lane",
|
||||||
|
title = "Walk down memory lane",
|
||||||
|
description = "Visit the place you first met or had your first real date and retell the story.",
|
||||||
|
category = "intimacy",
|
||||||
|
estimatedDuration = "1.5 hours",
|
||||||
|
estimatedCost = DateCostLevel.FREE,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
|
||||||
|
// Seasonal
|
||||||
|
DateIdea(
|
||||||
|
id = "seasonal_ice_skating",
|
||||||
|
title = "Ice skating",
|
||||||
|
description = "Hold hands, wobble, and warm up with hot cocoa afterward.",
|
||||||
|
category = "seasonal",
|
||||||
|
estimatedDuration = "2 hours",
|
||||||
|
estimatedCost = DateCostLevel.MEDIUM,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "seasonal_fall_foliage",
|
||||||
|
title = "Fall foliage drive",
|
||||||
|
description = "Take the prettiest route, stop for cider, and take a silly photo together.",
|
||||||
|
category = "seasonal",
|
||||||
|
estimatedDuration = "Half day",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "seasonal_beach_day",
|
||||||
|
title = "Off-season beach day",
|
||||||
|
description = "Go to the beach when it is nearly empty and walk the shoreline.",
|
||||||
|
category = "seasonal",
|
||||||
|
estimatedDuration = "Half day",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "seasonal_holiday_lights",
|
||||||
|
title = "Holiday lights walk",
|
||||||
|
description = "Find the most over-decorated neighborhood and stroll after dark.",
|
||||||
|
category = "seasonal",
|
||||||
|
estimatedDuration = "1 hour",
|
||||||
|
estimatedCost = DateCostLevel.FREE,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "seasonal_pumpkin_patch",
|
||||||
|
title = "Pumpkin patch or apple orchard",
|
||||||
|
description = "Pick seasonal produce and turn it into something tasty at home.",
|
||||||
|
category = "seasonal",
|
||||||
|
estimatedDuration = "Half day",
|
||||||
|
estimatedCost = DateCostLevel.LOW,
|
||||||
|
isPremium = false
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bonus evening-friendly ideas
|
||||||
|
DateIdea(
|
||||||
|
id = "romance_rooftop_drink",
|
||||||
|
title = "Rooftop drink at golden hour",
|
||||||
|
description = "Find the highest bar with outdoor space and watch the city light up.",
|
||||||
|
category = "romance",
|
||||||
|
estimatedDuration = "1.5 hours",
|
||||||
|
estimatedCost = DateCostLevel.MEDIUM,
|
||||||
|
isPremium = true
|
||||||
|
),
|
||||||
|
DateIdea(
|
||||||
|
id = "adventure_bike_ride",
|
||||||
|
title = "Neighborhood bike ride",
|
||||||
|
description = "Borrow or rent bikes and explore streets you usually drive past.",
|
||||||
|
category = "adventure",
|
||||||
|
estimatedDuration = "1.5 hours",
|
||||||
|
estimatedCost = DateCostLevel.FREE,
|
||||||
|
isPremium = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun byId(id: String): DateIdea? = all.find { it.id == id }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
package app.closer.data.repository
|
||||||
|
|
||||||
|
import app.closer.data.remote.FirestoreDateMatchDataSource
|
||||||
|
import app.closer.data.remote.FirestoreDateSwipeDataSource
|
||||||
|
import app.closer.domain.model.DateIdea
|
||||||
|
import app.closer.domain.model.DateMatch
|
||||||
|
import app.closer.domain.model.DateSwipe
|
||||||
|
import app.closer.domain.model.SwipeAction
|
||||||
|
import app.closer.domain.repository.DateMatchRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of [DateMatchRepository].
|
||||||
|
*
|
||||||
|
* Date ideas are currently loaded from an in-memory seed catalog that is also
|
||||||
|
* shipped as a Cloud Function seed. Firestore is the source of truth for
|
||||||
|
* per-partner swipes and revealed matches.
|
||||||
|
*
|
||||||
|
* Mutual match detection happens client-side after recording a swipe: if the
|
||||||
|
* current user swiped [SwipeAction.LOVE] and the partner also has a love swipe
|
||||||
|
* on the same date idea, a revealed match is created.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class DateMatchRepositoryImpl @Inject constructor(
|
||||||
|
private val swipeDataSource: FirestoreDateSwipeDataSource,
|
||||||
|
private val matchDataSource: FirestoreDateMatchDataSource
|
||||||
|
) : DateMatchRepository {
|
||||||
|
|
||||||
|
override suspend fun getDateIdeas(): List<DateIdea> = DateIdeaSeed.all
|
||||||
|
|
||||||
|
override suspend fun recordSwipe(
|
||||||
|
coupleId: String,
|
||||||
|
userId: String,
|
||||||
|
dateIdeaId: String,
|
||||||
|
action: SwipeAction
|
||||||
|
): Result<DateMatch?> = runCatching {
|
||||||
|
val swipe = DateSwipe(
|
||||||
|
dateIdeaId = dateIdeaId,
|
||||||
|
userId = userId,
|
||||||
|
action = action,
|
||||||
|
swipedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
swipeDataSource.recordSwipe(coupleId, swipe)
|
||||||
|
|
||||||
|
if (action != SwipeAction.LOVE) return@runCatching null
|
||||||
|
|
||||||
|
val existing = matchDataSource.findMatchByDateIdeaId(coupleId, dateIdeaId)
|
||||||
|
if (existing != null) return@runCatching existing
|
||||||
|
|
||||||
|
val allSwipes = swipeDataSource.getAllSwipesForDate(coupleId, dateIdeaId)
|
||||||
|
val partnerSwipe = allSwipes.firstOrNull { it.userId != userId && it.action == SwipeAction.LOVE }
|
||||||
|
?: return@runCatching null
|
||||||
|
|
||||||
|
val matchedBy = listOf(userId, partnerSwipe.userId).distinct().sorted()
|
||||||
|
val matchId = matchDataSource.createMatch(coupleId, dateIdeaId, matchedBy)
|
||||||
|
DateMatch(
|
||||||
|
id = matchId,
|
||||||
|
coupleId = coupleId,
|
||||||
|
dateIdeaId = dateIdeaId,
|
||||||
|
revealedAt = System.currentTimeMillis(),
|
||||||
|
matchedBy = matchedBy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getOwnSwipe(coupleId: String, userId: String, dateIdeaId: String): DateSwipe? {
|
||||||
|
return swipeDataSource.getSwipe(coupleId, dateIdeaId, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun observeOwnSwipes(coupleId: String, userId: String): Flow<List<DateSwipe>> {
|
||||||
|
return swipeDataSource.observeOwnSwipes(coupleId, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun observeMatches(coupleId: String): Flow<List<DateMatch>> {
|
||||||
|
return matchDataSource.observeMatches(coupleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPartnerSwipes(coupleId: String, partnerId: String): List<DateSwipe> {
|
||||||
|
return swipeDataSource.observeOwnSwipes(coupleId, partnerId, once = true).first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import app.closer.core.billing.EntitlementChecker
|
||||||
import app.closer.core.billing.FirestoreEntitlementChecker
|
import app.closer.core.billing.FirestoreEntitlementChecker
|
||||||
import app.closer.data.local.SettingsDataStore
|
import app.closer.data.local.SettingsDataStore
|
||||||
import app.closer.data.repository.CoupleRepositoryImpl
|
import app.closer.data.repository.CoupleRepositoryImpl
|
||||||
|
import app.closer.data.repository.DateMatchRepositoryImpl
|
||||||
|
import app.closer.domain.repository.DateMatchRepository
|
||||||
import app.closer.data.repository.QuestionSessionRepositoryImpl
|
import app.closer.data.repository.QuestionSessionRepositoryImpl
|
||||||
import app.closer.data.repository.FirebaseAuthRepositoryImpl
|
import app.closer.data.repository.FirebaseAuthRepositoryImpl
|
||||||
import app.closer.data.repository.InviteRepositoryImpl
|
import app.closer.data.repository.InviteRepositoryImpl
|
||||||
|
|
@ -42,6 +44,9 @@ abstract class RepositoryModule {
|
||||||
@Binds @Singleton
|
@Binds @Singleton
|
||||||
abstract fun bindCoupleRepository(impl: CoupleRepositoryImpl): CoupleRepository
|
abstract fun bindCoupleRepository(impl: CoupleRepositoryImpl): CoupleRepository
|
||||||
|
|
||||||
|
@Binds @Singleton
|
||||||
|
abstract fun bindDateMatchRepository(impl: DateMatchRepositoryImpl): DateMatchRepository
|
||||||
|
|
||||||
@Binds @Singleton
|
@Binds @Singleton
|
||||||
abstract fun bindQuestionThreadRepository(impl: QuestionThreadRepositoryImpl): QuestionThreadRepository
|
abstract fun bindQuestionThreadRepository(impl: QuestionThreadRepositoryImpl): QuestionThreadRepository
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package app.closer.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A curated date idea presented to both partners for independent swiping.
|
||||||
|
*
|
||||||
|
* @property id Stable Firestore document ID.
|
||||||
|
* @property title Short, evocative name shown on the card.
|
||||||
|
* @property description One or two sentences explaining the date.
|
||||||
|
* @property category Category key, aligned with question categories.
|
||||||
|
* @property estimatedDuration Approximate duration label (e.g. "30 min", "2–3 hours").
|
||||||
|
* @property estimatedCost Cost level: free, low, medium, high.
|
||||||
|
* @property isPremium True when the idea requires an active premium entitlement.
|
||||||
|
* @property createdAt Server timestamp of when the idea was added.
|
||||||
|
*/
|
||||||
|
data class DateIdea(
|
||||||
|
val id: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val category: String = "",
|
||||||
|
val estimatedDuration: String = "",
|
||||||
|
val estimatedCost: DateCostLevel = DateCostLevel.FREE,
|
||||||
|
val isPremium: Boolean = false,
|
||||||
|
val createdAt: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class DateCostLevel {
|
||||||
|
FREE,
|
||||||
|
LOW,
|
||||||
|
MEDIUM,
|
||||||
|
HIGH;
|
||||||
|
|
||||||
|
fun toFirestoreValue(): String = name.lowercase()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromFirestoreValue(value: String): DateCostLevel = when (value) {
|
||||||
|
"free" -> FREE
|
||||||
|
"low" -> LOW
|
||||||
|
"medium" -> MEDIUM
|
||||||
|
"high" -> HIGH
|
||||||
|
else -> FREE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package app.closer.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mutual match on a date idea.
|
||||||
|
*
|
||||||
|
* Document path: couples/{coupleId}/date_matches/{matchId}
|
||||||
|
* Only revealed after both partners have swiped [SwipeAction.LOVE].
|
||||||
|
*/
|
||||||
|
data class DateMatch(
|
||||||
|
val id: String = "",
|
||||||
|
val coupleId: String = "",
|
||||||
|
val dateIdeaId: String = "",
|
||||||
|
val dateIdea: DateIdea? = null,
|
||||||
|
val revealedAt: Long = 0L,
|
||||||
|
val matchedBy: List<String> = emptyList()
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package app.closer.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secondary suggestion shown in the matches screen.
|
||||||
|
*
|
||||||
|
* One partner swiped [SwipeAction.MAYBE] while the other swiped
|
||||||
|
* [SwipeAction.LOVE] or [SwipeAction.MAYBE]; the idea is worth discussing
|
||||||
|
* even though it is not a mutual love match.
|
||||||
|
*/
|
||||||
|
data class DateMatchSuggestion(
|
||||||
|
val dateIdeaId: String,
|
||||||
|
val dateIdea: DateIdea,
|
||||||
|
val partnerAction: SwipeAction
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package app.closer.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single partner's swipe action on a date idea.
|
||||||
|
*
|
||||||
|
* Document path: couples/{coupleId}/date_swipes/{dateId}
|
||||||
|
* The document contains a map of userId -> action.
|
||||||
|
*/
|
||||||
|
data class DateSwipe(
|
||||||
|
val dateIdeaId: String = "",
|
||||||
|
val userId: String = "",
|
||||||
|
val action: SwipeAction = SwipeAction.SKIP,
|
||||||
|
val swipedAt: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class SwipeAction {
|
||||||
|
LOVE,
|
||||||
|
MAYBE,
|
||||||
|
SKIP;
|
||||||
|
|
||||||
|
fun toFirestoreValue(): String = name.lowercase()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromFirestoreValue(value: String): SwipeAction = when (value) {
|
||||||
|
"love" -> LOVE
|
||||||
|
"maybe" -> MAYBE
|
||||||
|
else -> SKIP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package app.closer.domain.repository
|
||||||
|
|
||||||
|
import app.closer.domain.model.DateIdea
|
||||||
|
import app.closer.domain.model.DateMatch
|
||||||
|
import app.closer.domain.model.DateSwipe
|
||||||
|
import app.closer.domain.model.SwipeAction
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for the Date Match feature.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Surface date ideas to swipe
|
||||||
|
* - Record a user's swipe action
|
||||||
|
* - Reveal mutual love matches in real time
|
||||||
|
* - Surface secondary "maybe" suggestions
|
||||||
|
*/
|
||||||
|
interface DateMatchRepository {
|
||||||
|
|
||||||
|
/** Load all available date ideas. */
|
||||||
|
suspend fun getDateIdeas(): List<DateIdea>
|
||||||
|
|
||||||
|
/** Record the current user's swipe and return the resulting match if it was mutual love. */
|
||||||
|
suspend fun recordSwipe(coupleId: String, userId: String, dateIdeaId: String, action: SwipeAction): Result<DateMatch?>
|
||||||
|
|
||||||
|
/** Get the current user's own swipe for a date. */
|
||||||
|
suspend fun getOwnSwipe(coupleId: String, userId: String, dateIdeaId: String): DateSwipe?
|
||||||
|
|
||||||
|
/** Observe all swipes made by the current user. */
|
||||||
|
fun observeOwnSwipes(coupleId: String, userId: String): Flow<List<DateSwipe>>
|
||||||
|
|
||||||
|
/** Observe all revealed mutual matches for the couple. */
|
||||||
|
fun observeMatches(coupleId: String): Flow<List<DateMatch>>
|
||||||
|
|
||||||
|
/** Get partner's swipe actions for the current user to compute maybe suggestions. */
|
||||||
|
suspend fun getPartnerSwipes(coupleId: String, partnerId: String): List<DateSwipe>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,615 @@
|
||||||
|
package app.closer.ui.dates
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
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.offset
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import app.closer.domain.model.DateCostLevel
|
||||||
|
import app.closer.domain.model.DateIdea
|
||||||
|
import app.closer.domain.model.DateMatch
|
||||||
|
import app.closer.domain.model.SwipeAction
|
||||||
|
import app.closer.ui.components.EmptyState
|
||||||
|
import app.closer.ui.components.ErrorState
|
||||||
|
import app.closer.ui.components.LoadingState
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DateMatchScreen(
|
||||||
|
onNavigate: (String) -> Unit = {},
|
||||||
|
viewModel: DateMatchViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
DateMatchContent(
|
||||||
|
state = state,
|
||||||
|
onLove = viewModel::loveCurrent,
|
||||||
|
onMaybe = viewModel::maybeCurrent,
|
||||||
|
onSkip = viewModel::skipCurrent,
|
||||||
|
onDismissMatch = viewModel::dismissJustMatched,
|
||||||
|
onRetry = viewModel::retry,
|
||||||
|
onViewMatches = { onNavigate("date_matches") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DateMatchContent(
|
||||||
|
state: DateMatchUiState,
|
||||||
|
onLove: () -> Unit,
|
||||||
|
onMaybe: () -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
onDismissMatch: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onViewMatches: () -> 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
|
||||||
|
) {
|
||||||
|
DateMatchHeader(
|
||||||
|
matchCount = state.matches.size,
|
||||||
|
partnerName = state.partnerName,
|
||||||
|
onViewMatches = onViewMatches,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(top = 12.dp, bottom = 6.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
LoadingState(
|
||||||
|
message = "Loading date ideas…",
|
||||||
|
modifier = Modifier.padding(top = 120.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error != null -> {
|
||||||
|
ErrorState(
|
||||||
|
title = "Could not load ideas",
|
||||||
|
message = state.error ?: "Something went wrong.",
|
||||||
|
onRetry = onRetry,
|
||||||
|
modifier = Modifier.padding(top = 120.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
!state.hasMore -> {
|
||||||
|
EmptyState(
|
||||||
|
title = "You have browsed every idea",
|
||||||
|
body = buildString {
|
||||||
|
if (state.matches.isNotEmpty()) {
|
||||||
|
append("You and ")
|
||||||
|
append(state.partnerName ?: "your partner")
|
||||||
|
append(" have ")
|
||||||
|
append(state.matches.size)
|
||||||
|
append(" mutual match")
|
||||||
|
if (state.matches.size != 1) append("es")
|
||||||
|
append(". Revisit them anytime.")
|
||||||
|
} else {
|
||||||
|
append("No new ideas right now. Check back after your partner has swiped, or look at your matches.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actionLabel = if (state.matches.isNotEmpty()) "View matches" else null,
|
||||||
|
onAction = if (state.matches.isNotEmpty()) onViewMatches else null,
|
||||||
|
modifier = Modifier.padding(top = 120.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
CardStackArea(
|
||||||
|
current = state.currentIdea,
|
||||||
|
next = state.nextIdea,
|
||||||
|
onLove = onLove,
|
||||||
|
onMaybe = onMaybe,
|
||||||
|
onSkip = onSkip,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(480.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
ActionButtons(
|
||||||
|
onSkip = onSkip,
|
||||||
|
onMaybe = onMaybe,
|
||||||
|
onLove = onLove
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
state.justMatched?.let { match ->
|
||||||
|
MatchOverlay(
|
||||||
|
match = match,
|
||||||
|
partnerName = state.partnerName,
|
||||||
|
onDismiss = onDismissMatch,
|
||||||
|
onViewMatches = onViewMatches
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DateMatchHeader(
|
||||||
|
matchCount: Int,
|
||||||
|
partnerName: String?,
|
||||||
|
onViewMatches: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Date Match",
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = partnerName?.let { "Swiping with $it" } ?: "Find something you both love",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(0xFF5A5060)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onViewMatches) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = Color(0xFFF3E8FF)
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.padding(8.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Favorite,
|
||||||
|
contentDescription = "View matches",
|
||||||
|
tint = Color(0xFF56306F)
|
||||||
|
)
|
||||||
|
if (matchCount > 0) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = Color(0xFF8D2D35),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = matchCount.toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CardStackArea(
|
||||||
|
current: DateIdea?,
|
||||||
|
next: DateIdea?,
|
||||||
|
onLove: () -> Unit,
|
||||||
|
onMaybe: () -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
|
val animatedOffset by animateFloatAsState(
|
||||||
|
targetValue = dragOffset,
|
||||||
|
animationSpec = spring(stiffness = 200f, dampingRatio = 0.7f),
|
||||||
|
label = "cardDrag"
|
||||||
|
)
|
||||||
|
|
||||||
|
val swipeThreshold = 180f
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier,
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
// Background (next card)
|
||||||
|
next?.let { idea ->
|
||||||
|
DateCard(
|
||||||
|
idea = idea,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.9f)
|
||||||
|
.height(420.dp)
|
||||||
|
.scale(0.94f)
|
||||||
|
.offset(y = 16.dp),
|
||||||
|
dimmed = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreground (current card)
|
||||||
|
current?.let { idea ->
|
||||||
|
DateCard(
|
||||||
|
idea = idea,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(440.dp)
|
||||||
|
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragEnd = {
|
||||||
|
when {
|
||||||
|
dragOffset > swipeThreshold -> onLove()
|
||||||
|
dragOffset < -swipeThreshold -> onSkip()
|
||||||
|
else -> dragOffset = 0f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHorizontalDrag = { _, dragAmount ->
|
||||||
|
dragOffset += dragAmount
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
dimmed = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DateCard(
|
||||||
|
idea: DateIdea,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
dimmed: Boolean = false
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(32.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color.White.copy(alpha = if (dimmed) 0.7f else 0.96f)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = if (dimmed) 1.dp else 6.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CategoryPill(idea.category.displayDateCategory())
|
||||||
|
if (idea.isPremium) {
|
||||||
|
PremiumBadge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = idea.title,
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = idea.description,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 6,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
InfoChip(label = idea.estimatedDuration)
|
||||||
|
InfoChip(label = idea.estimatedCost.displayLabel())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PremiumBadge() {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = Color(0xFFFFD8EB)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Star,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = Color(0xFF8A226F)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Premium",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color(0xFF8A226F),
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CategoryPill(label: String) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = Color(0xFFF3E8FF)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color(0xFF56306F),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoChip(label: String) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = Color(0xFFFFF8FC)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color(0xFF5A5060)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActionButtons(
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
onMaybe: () -> Unit,
|
||||||
|
onLove: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CircularActionButton(
|
||||||
|
icon = Icons.Filled.Close,
|
||||||
|
contentDescription = "Skip",
|
||||||
|
color = Color(0xFFFFE5E7),
|
||||||
|
iconColor = Color(0xFF8D2D35),
|
||||||
|
onClick = onSkip
|
||||||
|
)
|
||||||
|
CircularActionButton(
|
||||||
|
icon = Icons.Filled.Star,
|
||||||
|
contentDescription = "Maybe",
|
||||||
|
color = Color(0xFFFFF8E1),
|
||||||
|
iconColor = Color(0xFF6B5D00),
|
||||||
|
onClick = onMaybe,
|
||||||
|
size = 72.dp
|
||||||
|
)
|
||||||
|
CircularActionButton(
|
||||||
|
icon = Icons.Filled.Favorite,
|
||||||
|
contentDescription = "Love",
|
||||||
|
color = Color(0xFFFFE3F0),
|
||||||
|
iconColor = Color(0xFF9B1B5A),
|
||||||
|
onClick = onLove
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CircularActionButton(
|
||||||
|
icon: ImageVector,
|
||||||
|
contentDescription: String,
|
||||||
|
color: Color,
|
||||||
|
iconColor: Color,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
size: androidx.compose.ui.unit.Dp = 64.dp
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.size(size)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = color,
|
||||||
|
shadowElevation = 4.dp
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.size(size / 2.2f),
|
||||||
|
tint = iconColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MatchOverlay(
|
||||||
|
match: DateMatch,
|
||||||
|
partnerName: String?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onViewMatches: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0xFF261D2E).copy(alpha = 0.54f))
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.White)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = Color(0xFFFFE3F0),
|
||||||
|
modifier = Modifier.size(72.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Favorite,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
tint = Color(0xFF9B1B5A)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "It is a match!",
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = buildString {
|
||||||
|
append("You and ")
|
||||||
|
append(partnerName ?: "your partner")
|
||||||
|
append(" both loved this idea.")
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color(0xFF5A5060)
|
||||||
|
)
|
||||||
|
|
||||||
|
match.dateIdea?.let { idea ->
|
||||||
|
DateCard(
|
||||||
|
idea = idea,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(220.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFFF3E8FF),
|
||||||
|
contentColor = Color(0xFF56306F)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Keep swiping")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onViewMatches,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFFB98AF4),
|
||||||
|
contentColor = Color(0xFF271236)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("View matches")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.displayDateCategory(): String =
|
||||||
|
replace("_", " ")
|
||||||
|
.split(" ")
|
||||||
|
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercaseChar() } }
|
||||||
|
|
||||||
|
private fun DateCostLevel.displayLabel(): String = when (this) {
|
||||||
|
DateCostLevel.FREE -> "Free"
|
||||||
|
DateCostLevel.LOW -> "$"
|
||||||
|
DateCostLevel.MEDIUM -> "$$"
|
||||||
|
DateCostLevel.HIGH -> "$$$"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
package app.closer.ui.dates
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.domain.model.DateIdea
|
||||||
|
import app.closer.domain.model.DateMatch
|
||||||
|
import app.closer.domain.model.DateSwipe
|
||||||
|
import app.closer.domain.model.SwipeAction
|
||||||
|
import app.closer.domain.repository.AuthRepository
|
||||||
|
import app.closer.domain.repository.CoupleRepository
|
||||||
|
import app.closer.domain.repository.DateMatchRepository
|
||||||
|
import app.closer.domain.repository.UserRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class DateMatchUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val error: String? = null,
|
||||||
|
val coupleId: String? = null,
|
||||||
|
val currentUserId: String? = null,
|
||||||
|
val partnerName: String? = null,
|
||||||
|
val dateIdeas: List<DateIdea> = emptyList(),
|
||||||
|
val ownSwipes: Map<String, SwipeAction> = emptyMap(),
|
||||||
|
val matches: List<DateMatch> = emptyList(),
|
||||||
|
val currentIndex: Int = 0,
|
||||||
|
val justMatched: DateMatch? = null
|
||||||
|
) {
|
||||||
|
val currentIdea: DateIdea? get() = dateIdeas.getOrNull(currentIndex)
|
||||||
|
val nextIdea: DateIdea? get() = dateIdeas.getOrNull(currentIndex + 1)
|
||||||
|
val hasMore: Boolean get() = currentIndex < dateIdeas.size
|
||||||
|
|
||||||
|
fun userAction(ideaId: String): SwipeAction? = ownSwipes[ideaId]
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class DateMatchViewModel @Inject constructor(
|
||||||
|
private val repository: DateMatchRepository,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val coupleRepository: CoupleRepository,
|
||||||
|
private val userRepository: UserRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(DateMatchUiState())
|
||||||
|
val uiState: StateFlow<DateMatchUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadDateMatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadDateMatch() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = DateMatchUiState(isLoading = true)
|
||||||
|
try {
|
||||||
|
val uid = authRepository.currentUserId
|
||||||
|
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
|
||||||
|
val partnerName = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId ->
|
||||||
|
runCatching { userRepository.getUser(partnerId)?.displayName }.getOrNull()
|
||||||
|
}
|
||||||
|
val ideas = repository.getDateIdeas()
|
||||||
|
_uiState.value = DateMatchUiState(
|
||||||
|
isLoading = false,
|
||||||
|
coupleId = couple?.id,
|
||||||
|
currentUserId = uid,
|
||||||
|
partnerName = partnerName,
|
||||||
|
dateIdeas = ideas
|
||||||
|
)
|
||||||
|
|
||||||
|
if (couple != null && uid != null) {
|
||||||
|
observeData(couple.id, uid)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = DateMatchUiState(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Could not load date ideas."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeData(coupleId: String, userId: String) {
|
||||||
|
val swipesFlow: Flow<List<DateSwipe>> = repository.observeOwnSwipes(coupleId, userId)
|
||||||
|
val matchesFlow: Flow<List<DateMatch>> = repository.observeMatches(coupleId)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(swipesFlow, matchesFlow) { swipes, matches ->
|
||||||
|
_uiState.value.copy(
|
||||||
|
ownSwipes = swipes.associate { it.dateIdeaId to it.action },
|
||||||
|
matches = matches
|
||||||
|
)
|
||||||
|
}.collect { _uiState.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun swipeCurrent(action: SwipeAction) {
|
||||||
|
val current = _uiState.value.currentIdea ?: return
|
||||||
|
val coupleId = _uiState.value.coupleId ?: return
|
||||||
|
val userId = _uiState.value.currentUserId ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(justMatched = null) }
|
||||||
|
val result = repository.recordSwipe(coupleId, userId, current.id, action)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { match ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
currentIndex = it.currentIndex + 1,
|
||||||
|
justMatched = match
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { err ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(error = err.message ?: "Could not save swipe.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipCurrent() = swipeCurrent(SwipeAction.SKIP)
|
||||||
|
fun maybeCurrent() = swipeCurrent(SwipeAction.MAYBE)
|
||||||
|
fun loveCurrent() = swipeCurrent(SwipeAction.LOVE)
|
||||||
|
|
||||||
|
fun dismissJustMatched() {
|
||||||
|
_uiState.update { it.copy(justMatched = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retry() = loadDateMatch()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,381 @@
|
||||||
|
package app.closer.ui.dates
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.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.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.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
|
||||||
|
import app.closer.domain.model.DateCostLevel
|
||||||
|
import app.closer.domain.model.DateIdea
|
||||||
|
import app.closer.domain.model.DateMatch
|
||||||
|
import app.closer.domain.model.DateMatchSuggestion
|
||||||
|
import app.closer.domain.model.SwipeAction
|
||||||
|
import app.closer.ui.components.EmptyState
|
||||||
|
import app.closer.ui.components.ErrorState
|
||||||
|
import app.closer.ui.components.LoadingState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DateMatchesScreen(
|
||||||
|
onNavigate: (String) -> Unit = {},
|
||||||
|
viewModel: DateMatchesViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
DateMatchesContent(
|
||||||
|
state = state,
|
||||||
|
onRetry = viewModel::retry,
|
||||||
|
onBack = { onNavigate("back") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DateMatchesContent(
|
||||||
|
state: DateMatchesUiState,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
|
||||||
|
start = Offset.Zero,
|
||||||
|
end = Offset.Infinite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(top = 20.dp, bottom = 6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Your Matches",
|
||||||
|
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = state.partnerName?.let { "Ideas you and $it both love" }
|
||||||
|
?: "Mutual love matches appear here",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color(0xFF5A5060)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.isLoading -> item {
|
||||||
|
LoadingState(
|
||||||
|
message = "Loading matches…",
|
||||||
|
modifier = Modifier.padding(top = 80.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error != null -> item {
|
||||||
|
ErrorState(
|
||||||
|
title = "Could not load matches",
|
||||||
|
message = state.error ?: "Something went wrong.",
|
||||||
|
onRetry = onRetry,
|
||||||
|
modifier = Modifier.padding(top = 80.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.mutualMatches.isEmpty() && state.maybeMatches.isEmpty() -> item {
|
||||||
|
EmptyState(
|
||||||
|
title = "No matches yet",
|
||||||
|
body = buildString {
|
||||||
|
append("Start swiping on date ideas. When you and ")
|
||||||
|
append(state.partnerName ?: "your partner")
|
||||||
|
append(" both love the same idea, it will appear here.")
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(top = 80.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (state.mutualMatches.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
SectionHeader(
|
||||||
|
title = "Mutual love",
|
||||||
|
count = state.mutualMatches.size,
|
||||||
|
icon = Icons.Filled.Favorite,
|
||||||
|
iconColor = Color(0xFF9B1B5A),
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(state.mutualMatches, key = { it.id }) { match ->
|
||||||
|
MatchCard(match = match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.maybeMatches.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
SectionHeader(
|
||||||
|
title = "Maybe together",
|
||||||
|
count = state.maybeMatches.size,
|
||||||
|
icon = Icons.Filled.Star,
|
||||||
|
iconColor = Color(0xFF6B5D00),
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(state.maybeMatches, key = { it.dateIdeaId }) { suggestion ->
|
||||||
|
SuggestionCard(suggestion = suggestion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Box(modifier = Modifier.padding(bottom = 24.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(
|
||||||
|
title: String,
|
||||||
|
count: Int,
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
iconColor: Color,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = iconColor,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = Color(0xFFFFF8FC)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = count.toString(),
|
||||||
|
modifier = Modifier.padding(horizontal = 11.dp, vertical = 5.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color(0xFF5A5060)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MatchCard(match: DateMatch) {
|
||||||
|
val idea = match.dateIdea ?: return
|
||||||
|
IdeaCard(
|
||||||
|
idea = idea,
|
||||||
|
badge = {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = Color(0xFFFFE3F0)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Favorite,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = Color(0xFF9B1B5A)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Matched",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color(0xFF9B1B5A),
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SuggestionCard(suggestion: DateMatchSuggestion) {
|
||||||
|
IdeaCard(
|
||||||
|
idea = suggestion.dateIdea,
|
||||||
|
badge = {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = Color(0xFFFFF8E1)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Star,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = Color(0xFF6B5D00)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (suggestion.partnerAction == SwipeAction.LOVE) "They love it" else "Maybe match",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color(0xFF6B5D00),
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IdeaCard(
|
||||||
|
idea: DateIdea,
|
||||||
|
badge: @Composable () -> 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)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(18.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = Color(0xFFF3E8FF)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = idea.category.displayDateCategory(),
|
||||||
|
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color(0xFF56306F),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
badge()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = idea.title,
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = idea.description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(0xFF5A5060),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
InfoChip(label = idea.estimatedDuration)
|
||||||
|
InfoChip(label = idea.estimatedCost.displayLabel())
|
||||||
|
if (idea.isPremium) {
|
||||||
|
InfoChip(label = "Premium", emphasis = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoChip(
|
||||||
|
label: String,
|
||||||
|
emphasis: Boolean = false
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = if (emphasis) Color(0xFFFFD8EB) else Color(0xFFFFF8FC)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = if (emphasis) Color(0xFF8A226F) else Color(0xFF5A5060),
|
||||||
|
fontWeight = if (emphasis) FontWeight.SemiBold else FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.displayDateCategory(): String =
|
||||||
|
replace("_", " ")
|
||||||
|
.split(" ")
|
||||||
|
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercaseChar() } }
|
||||||
|
|
||||||
|
private fun DateCostLevel.displayLabel(): String = when (this) {
|
||||||
|
DateCostLevel.FREE -> "Free"
|
||||||
|
DateCostLevel.LOW -> "$"
|
||||||
|
DateCostLevel.MEDIUM -> "$$"
|
||||||
|
DateCostLevel.HIGH -> "$$$"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
package app.closer.ui.dates
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.data.repository.DateIdeaSeed
|
||||||
|
import app.closer.domain.model.DateIdea
|
||||||
|
import app.closer.domain.model.DateMatch
|
||||||
|
import app.closer.domain.model.DateMatchSuggestion
|
||||||
|
import app.closer.domain.model.DateSwipe
|
||||||
|
import app.closer.domain.model.SwipeAction
|
||||||
|
import app.closer.domain.repository.AuthRepository
|
||||||
|
import app.closer.domain.repository.CoupleRepository
|
||||||
|
import app.closer.domain.repository.DateMatchRepository
|
||||||
|
import app.closer.domain.repository.UserRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class DateMatchesUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val error: String? = null,
|
||||||
|
val coupleId: String? = null,
|
||||||
|
val partnerName: String? = null,
|
||||||
|
val mutualMatches: List<DateMatch> = emptyList(),
|
||||||
|
val maybeMatches: List<DateMatchSuggestion> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DateMatchSuggestion(
|
||||||
|
val dateIdeaId: String,
|
||||||
|
val dateIdea: DateIdea,
|
||||||
|
val partnerAction: SwipeAction
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class DateMatchesViewModel @Inject constructor(
|
||||||
|
private val repository: DateMatchRepository,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val coupleRepository: CoupleRepository,
|
||||||
|
private val userRepository: UserRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(DateMatchesUiState())
|
||||||
|
val uiState: StateFlow<DateMatchesUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadMatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMatches() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = DateMatchesUiState(isLoading = true)
|
||||||
|
try {
|
||||||
|
val uid = authRepository.currentUserId
|
||||||
|
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
|
||||||
|
val partnerId = couple?.userIds?.firstOrNull { it != uid }
|
||||||
|
val partnerName = partnerId?.let { id ->
|
||||||
|
runCatching { userRepository.getUser(id)?.displayName }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = DateMatchesUiState(
|
||||||
|
isLoading = false,
|
||||||
|
coupleId = couple?.id,
|
||||||
|
partnerName = partnerName
|
||||||
|
)
|
||||||
|
|
||||||
|
if (couple != null && uid != null && partnerId != null) {
|
||||||
|
observeMatches(couple.id, uid, partnerId)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = DateMatchesUiState(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Could not load matches."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeMatches(coupleId: String, userId: String, partnerId: String) {
|
||||||
|
val matchesFlow: Flow<List<DateMatch>> = repository.observeMatches(coupleId)
|
||||||
|
val partnerSwipesFlow: Flow<List<DateSwipe>> = repository.observeOwnSwipes(coupleId, partnerId)
|
||||||
|
val ownSwipesFlow: Flow<List<DateSwipe>> = repository.observeOwnSwipes(coupleId, userId)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(matchesFlow, partnerSwipesFlow, ownSwipesFlow) { matches, partnerSwipes, ownSwipes ->
|
||||||
|
val ownLoveIds = ownSwipes
|
||||||
|
.filter { it.action == SwipeAction.LOVE }
|
||||||
|
.map { it.dateIdeaId }
|
||||||
|
.toSet()
|
||||||
|
val ownMaybeIds = ownSwipes
|
||||||
|
.filter { it.action == SwipeAction.MAYBE }
|
||||||
|
.map { it.dateIdeaId }
|
||||||
|
.toSet()
|
||||||
|
val matchedIdeaIds = matches.map { it.dateIdeaId }.toSet()
|
||||||
|
|
||||||
|
val maybeSuggestions = partnerSwipes
|
||||||
|
.filter { it.action != SwipeAction.SKIP }
|
||||||
|
.mapNotNull { partnerSwipe ->
|
||||||
|
val ideaId = partnerSwipe.dateIdeaId
|
||||||
|
if (ideaId in matchedIdeaIds) return@mapNotNull null
|
||||||
|
val ownLoved = ideaId in ownLoveIds
|
||||||
|
val ownMaybe = ideaId in ownMaybeIds
|
||||||
|
when {
|
||||||
|
ownLoved && partnerSwipe.action == SwipeAction.MAYBE -> ideaId to SwipeAction.MAYBE
|
||||||
|
ownMaybe && partnerSwipe.action == SwipeAction.LOVE -> ideaId to SwipeAction.LOVE
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mapNotNull { (ideaId, partnerAction) ->
|
||||||
|
DateIdeaSeed.byId(ideaId)?.let {
|
||||||
|
DateMatchSuggestion(ideaId, it, partnerAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val enrichedMatches = matches.map { match ->
|
||||||
|
match.copy(dateIdea = DateIdeaSeed.byId(match.dateIdeaId))
|
||||||
|
}
|
||||||
|
|
||||||
|
DateMatchesUiState(
|
||||||
|
isLoading = false,
|
||||||
|
coupleId = coupleId,
|
||||||
|
partnerName = _uiState.value.partnerName,
|
||||||
|
mutualMatches = enrichedMatches,
|
||||||
|
maybeMatches = maybeSuggestions
|
||||||
|
)
|
||||||
|
}.collect { _uiState.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retry() = loadMatches()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
# Date Planning Feature Roadmap
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
Build a complete Date Operating System for Closer — not a simple list of date ideas.
|
||||||
|
|
||||||
|
Most couples don't struggle to find ideas. They struggle to:
|
||||||
|
- Decide what to do
|
||||||
|
- Find something both partners want
|
||||||
|
- Schedule it
|
||||||
|
- Follow through
|
||||||
|
- Remember meaningful moments afterward
|
||||||
|
|
||||||
|
The goal is to create a system that helps couples plan, experience, and reflect on dates together — built on top of Closer's existing daily question + spin wheel engagement loop.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
### Primary Goals
|
||||||
|
- Increase relationship engagement beyond daily questions
|
||||||
|
- Encourage regular quality time
|
||||||
|
- Create meaningful shared memories
|
||||||
|
- Improve user retention
|
||||||
|
- Create premium upgrade opportunities
|
||||||
|
|
||||||
|
### Success Metrics
|
||||||
|
- Dates planned per month
|
||||||
|
- Dates completed per month
|
||||||
|
- Couple retention
|
||||||
|
- Premium conversion rate
|
||||||
|
- Date reflection completion rate
|
||||||
|
|
||||||
|
## Existing App Context
|
||||||
|
|
||||||
|
### What's Already Built
|
||||||
|
- Daily question core loop (answer → reveal → history)
|
||||||
|
- Spin wheel category picker + question sessions
|
||||||
|
- Question packs with premium gating
|
||||||
|
- Partner invite system (code + email)
|
||||||
|
- Firestore-backed auth, couples, sessions, threads
|
||||||
|
- RevenueCat SDK wired (`purchases:8.20.0`) — purchase flows not yet implemented
|
||||||
|
- FCM push notifications (daily reminders, partner answered)
|
||||||
|
- Room local database (answers, questions, categories)
|
||||||
|
- Hilt DI, Material 3, Jetpack Compose
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Language | Kotlin 2.x |
|
||||||
|
| UI | Jetpack Compose + Material 3 |
|
||||||
|
| Navigation | Navigation Compose |
|
||||||
|
| Backend | Firebase (Auth, Firestore, Functions, FCM, Remote Config) |
|
||||||
|
| Payments | RevenueCat (`purchases:8.20.0`) |
|
||||||
|
| Local Storage | Room + DataStore + EncryptedSharedPreferences |
|
||||||
|
| DI | Hilt |
|
||||||
|
| Analytics | Firebase Analytics (hashed IDs, coarse categories) |
|
||||||
|
| Security | ProGuard/R8, SSL pinning, App Check, fail-closed webhooks |
|
||||||
|
|
||||||
|
### Architecture Fit
|
||||||
|
New date planning features slot into the existing architecture:
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── data/
|
||||||
|
│ ├── local/ # Room: date history, reflections, bucket list
|
||||||
|
│ ├── remote/ # Firestore: shared date plans, couple bucket list
|
||||||
|
│ └── repository/ # Repository implementations
|
||||||
|
├── domain/
|
||||||
|
│ ├── model/ # Date, DatePlan, BucketListItem, Reflection data classes
|
||||||
|
│ └── repository/ # Repository interfaces
|
||||||
|
├── ui/
|
||||||
|
│ ├── dates/ # NEW: date discovery, match, builder, history, reflection
|
||||||
|
│ ├── questions/ # Existing daily question flow
|
||||||
|
│ ├── wheel/ # Existing spin wheel
|
||||||
|
│ └── ...
|
||||||
|
├── core/
|
||||||
|
│ ├── navigation/ # New AppRoute.Dates section
|
||||||
|
│ ├── billing/ # Premium date pack gating
|
||||||
|
│ └── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 1: Date Discovery
|
||||||
|
|
||||||
|
### Date Match
|
||||||
|
|
||||||
|
Both partners browse date ideas independently — like the existing question reveal mechanic, but for dates.
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- Love ❤️
|
||||||
|
- Maybe 💛
|
||||||
|
- Skip 👎
|
||||||
|
|
||||||
|
Only mutual "Love" matches appear. "Maybe" matches surface as secondary suggestions.
|
||||||
|
|
||||||
|
**Firestore model:**
|
||||||
|
- `couples/{coupleId}/date_swipes/{dateId}` — per-partner swipe state
|
||||||
|
- Revealed matches stored in `couples/{coupleId}/date_matches/{matchId}`
|
||||||
|
|
||||||
|
### Date Builder
|
||||||
|
|
||||||
|
Both partners contribute preferences. Builder assembles a plan.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
- Date
|
||||||
|
- Time
|
||||||
|
- Budget
|
||||||
|
- Duration
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
- Activity suggestion
|
||||||
|
- Food suggestion
|
||||||
|
- Conversation prompts (draw from existing question packs)
|
||||||
|
- Optional challenge
|
||||||
|
|
||||||
|
**Local-first:** Builder preferences stored in Room. Generated plan synced to Firestore couple doc.
|
||||||
|
|
||||||
|
### Couple Bucket List
|
||||||
|
|
||||||
|
Shared list both partners can add to and check off.
|
||||||
|
|
||||||
|
Categories (aligned with existing question categories):
|
||||||
|
- Adventure
|
||||||
|
- Travel
|
||||||
|
- Food
|
||||||
|
- Learning
|
||||||
|
- Romance
|
||||||
|
- Intimacy
|
||||||
|
- Seasonal
|
||||||
|
|
||||||
|
**Firestore model:**
|
||||||
|
- `couples/{coupleId}/bucket_list/{itemId}` — shared list with added-by, completed-by, completed-at fields
|
||||||
|
|
||||||
|
## Phase 2: Date History and Reflection
|
||||||
|
|
||||||
|
### Date History
|
||||||
|
|
||||||
|
Store completed dates for the couple.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- Date, time, location
|
||||||
|
- Photos (Firebase Storage)
|
||||||
|
- Notes (per partner)
|
||||||
|
- Category
|
||||||
|
- Cost
|
||||||
|
- Rating (1–5, per partner)
|
||||||
|
|
||||||
|
**Firestore model:**
|
||||||
|
- `couples/{coupleId}/date_history/{dateId}` — shared history
|
||||||
|
- Photos stored in Firebase Storage under `couples/{coupleId}/date_photos/{photoId}`
|
||||||
|
|
||||||
|
### Private Date Reflection
|
||||||
|
|
||||||
|
After a date, both partners answer reflection questions privately. Answers reveal when both complete — same mechanic as the existing answer reveal flow.
|
||||||
|
|
||||||
|
Questions:
|
||||||
|
- What was your favorite moment?
|
||||||
|
- What surprised you?
|
||||||
|
- What did you appreciate most?
|
||||||
|
|
||||||
|
This reuses the existing `sessions`/`answers`/`threads` Firestore structure but with a date reflection type flag.
|
||||||
|
|
||||||
|
### Relationship Insights
|
||||||
|
|
||||||
|
Aggregate from date history + existing question engagement.
|
||||||
|
|
||||||
|
Track:
|
||||||
|
- Connection score (composite: date frequency + question engagement + reflection completion)
|
||||||
|
- Date satisfaction (average rating)
|
||||||
|
- Date frequency (per month)
|
||||||
|
- Preferred categories (from date history + question packs)
|
||||||
|
|
||||||
|
**Computed client-side** from local Room cache + Firestore snapshots. No server aggregation needed for MVP.
|
||||||
|
|
||||||
|
## Phase 3: Smart Planning
|
||||||
|
|
||||||
|
### AI Date Concierge
|
||||||
|
|
||||||
|
Firebase Cloud Function that generates personalized date plans.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
- Budget
|
||||||
|
- Available time
|
||||||
|
- Weather (OpenWeather API or similar)
|
||||||
|
- Interests (from question categories + date history)
|
||||||
|
- Relationship stage (from account age + date history)
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
- Complete itinerary
|
||||||
|
- Estimated cost breakdown
|
||||||
|
- Conversation prompts (pull from existing premium question packs)
|
||||||
|
- Backup indoor plan
|
||||||
|
|
||||||
|
**Implementation:** Firebase Cloud Function calling an LLM API (OpenAI / Anthropic). Results cached in Firestore.
|
||||||
|
|
||||||
|
### Smart Scheduling
|
||||||
|
|
||||||
|
Track partner availability and suggest date windows.
|
||||||
|
|
||||||
|
- Availability shared via Firestore (simple "free evenings" or calendar integration later)
|
||||||
|
- Preferred date frequency set per couple
|
||||||
|
- Auto-suggest when both partners have a free window + haven't had a date recently
|
||||||
|
|
||||||
|
## Premium Features
|
||||||
|
|
||||||
|
### Premium Date Packs
|
||||||
|
|
||||||
|
Curated date collections — same pack model as existing question packs.
|
||||||
|
|
||||||
|
- First Year Together
|
||||||
|
- Married With Kids
|
||||||
|
- Long Distance
|
||||||
|
- Anniversary Pack
|
||||||
|
- Reconnection Pack
|
||||||
|
- Summer Dates
|
||||||
|
- Winter Dates
|
||||||
|
|
||||||
|
**Implementation:** Same Firestore pack structure + RevenueCat entitlement gating as existing question packs.
|
||||||
|
|
||||||
|
### Advanced AI Concierge
|
||||||
|
|
||||||
|
- Unlimited AI-generated plans (free tier: 1/month)
|
||||||
|
- Personalized recommendations based on date history
|
||||||
|
- Seasonal suggestions
|
||||||
|
- Local event integration (later: Google Places / Yelp API)
|
||||||
|
|
||||||
|
### Premium Insights
|
||||||
|
|
||||||
|
- Date trends over time
|
||||||
|
- Relationship growth patterns
|
||||||
|
- Favorite activities
|
||||||
|
- Monthly summaries
|
||||||
|
|
||||||
|
## Killer Feature: Date Replay
|
||||||
|
|
||||||
|
Both partners answer after a date:
|
||||||
|
- Best moment
|
||||||
|
- Unexpected moment
|
||||||
|
- One thing I appreciated
|
||||||
|
|
||||||
|
Months later, they can revisit those reflections as a relationship timeline — like the existing answer history, but for real experiences.
|
||||||
|
|
||||||
|
**Implementation:** Reuses the reveal mechanic. Reflections stored as date-typed sessions. Timeline view queries all completed date sessions sorted chronologically.
|
||||||
|
|
||||||
|
## Recommended Build Order
|
||||||
|
|
||||||
|
### MVP — Phase 1 (v1.1.x)
|
||||||
|
|
||||||
|
1. **Date Match** — swipe UI + mutual match reveal (reuses reveal pattern)
|
||||||
|
2. **Date Builder** — preference input + plan generation
|
||||||
|
3. **Couple Bucket List** — shared list with checkoff
|
||||||
|
|
||||||
|
### Version 1.2
|
||||||
|
|
||||||
|
4. **Date History** — log completed dates + photos
|
||||||
|
5. **Private Reflections** — post-date questions with reveal mechanic
|
||||||
|
6. **Relationship Insights** — aggregate dashboard
|
||||||
|
|
||||||
|
### Version 1.3
|
||||||
|
|
||||||
|
7. **Smart Scheduling** — availability + suggestions
|
||||||
|
8. **AI Date Concierge** — Cloud Function + LLM integration
|
||||||
|
|
||||||
|
### Version 2.0
|
||||||
|
|
||||||
|
9. **Date Replay** — relationship timeline from reflections
|
||||||
|
10. **Advanced Analytics** — trends + growth patterns
|
||||||
|
11. **Premium Date Packs** — curated collections
|
||||||
|
12. **Local Event Integration** — Places/Yelp API
|
||||||
|
|
||||||
|
## Long Term Positioning
|
||||||
|
|
||||||
|
Closer becomes a full relationship platform where couples can:
|
||||||
|
- Communicate (daily questions, spin wheel)
|
||||||
|
- Plan experiences (date match, builder, AI concierge)
|
||||||
|
- Build memories (date history, reflections, replay)
|
||||||
|
- Track relationship growth (insights, analytics)
|
||||||
|
- Strengthen connection over time
|
||||||
|
|
||||||
|
Every feature builds on the same engagement loop: **private input → mutual reveal → shared memory**.
|
||||||
|
|
@ -43,6 +43,10 @@ service cloud.firestore {
|
||||||
return fields.every(f => resource.data[f] == request.resource.data[f]);
|
return fields.every(f => resource.data[f] == request.resource.data[f]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidSwipeAction(action) {
|
||||||
|
return action == 'love' || action == 'maybe' || action == 'skip';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Users ─────────────────────────────────────────────────────────────────
|
// ── Users ─────────────────────────────────────────────────────────────────
|
||||||
// Each user owns exactly their own document.
|
// Each user owns exactly their own document.
|
||||||
// hasPremium is server-only: clients may not write it directly.
|
// hasPremium is server-only: clients may not write it directly.
|
||||||
|
|
@ -55,6 +59,15 @@ service cloud.firestore {
|
||||||
&& !request.resource.data.diff(resource.data).affectedKeys().hasAny(['hasPremium']);
|
&& !request.resource.data.diff(resource.data).affectedKeys().hasAny(['hasPremium']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Date ideas (read-only catalog) ─────────────────────────────────────────
|
||||||
|
// Curated date ideas are readable by any authenticated user.
|
||||||
|
// Writes are server-only (admin SDK / Cloud Functions seeding).
|
||||||
|
|
||||||
|
match /date_ideas/{dateIdeaId} {
|
||||||
|
allow read: if isSignedIn();
|
||||||
|
allow create, update, delete: if false;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Invite codes ──────────────────────────────────────────────────────────
|
// ── Invite codes ──────────────────────────────────────────────────────────
|
||||||
// Invite system with proper ownership, validation, and expiry checks.
|
// Invite system with proper ownership, validation, and expiry checks.
|
||||||
|
|
||||||
|
|
@ -223,6 +236,34 @@ service cloud.firestore {
|
||||||
&& resource.data.userId == request.auth.uid;
|
&& resource.data.userId == request.auth.uid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Date swipes: per-couple, per-date partner swipe state.
|
||||||
|
match /date_swipes/{dateIdeaId} {
|
||||||
|
// Read: both couple members can read the shared swipe document.
|
||||||
|
allow read: if isCouplesMember(coupleId);
|
||||||
|
|
||||||
|
// Create/Update: each member can only write their own action entry.
|
||||||
|
// The payload must contain an actions.{uid} object with a valid action.
|
||||||
|
allow create, update: if isCouplesMember(coupleId)
|
||||||
|
// The path to the current user's action must exist and be the only action written
|
||||||
|
&& 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'])
|
||||||
|
&& isValidSwipeAction(request.resource.data.actions[request.auth.uid].action)
|
||||||
|
&& request.resource.data.actions[request.auth.uid].action != null
|
||||||
|
&& request.resource.data.actions[request.auth.uid].swipedAt is timestamp;
|
||||||
|
|
||||||
|
// Delete: server-only (admin SDK). Admin SDK bypasses rules.
|
||||||
|
allow delete: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date matches: revealed mutual love matches.
|
||||||
|
// Clients can read; creation of a match is performed by a Cloud Function
|
||||||
|
// after both partners have swiped 'love'. Direct client writes are denied.
|
||||||
|
match /date_matches/{matchId} {
|
||||||
|
allow read: if isCouplesMember(coupleId);
|
||||||
|
allow create, update, delete: if false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue