feat(dates): add Date Match MVP Phase 1 — swipe UI, Firestore models, 30+ seed ideas, match reveal

This commit is contained in:
null 2026-06-16 23:30:58 -05:00
parent 1fc25d6c1f
commit 512a6c9f42
18 changed files with 2437 additions and 1 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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()
)
}
}

View File

@ -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
)
}
}

View File

@ -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 = "23 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 = "12 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 = "34 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 = "23 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 = "23 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 = "23 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 = "23 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 = "34 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 }
}

View File

@ -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()
}
}

View File

@ -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

View File

@ -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", "23 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
}
}
}

View File

@ -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()
)

View File

@ -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
)

View File

@ -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
}
}
}

View File

@ -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>
}

View File

@ -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 -> "$$$"
}

View File

@ -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()
}

View File

@ -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 -> "$$$"
}

View File

@ -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()
}

View File

@ -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 (15, 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**.

View File

@ -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;
}
} }
} }
} }