diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index d01972cb..cd1fc94a 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -42,6 +42,8 @@ import app.closer.ui.pairing.AcceptInviteScreen import app.closer.ui.pairing.CreateInviteScreen import app.closer.ui.pairing.EmailInviteScreen import app.closer.ui.pairing.InviteConfirmScreen +import app.closer.ui.dates.DateMatchScreen +import app.closer.ui.dates.DateMatchesScreen import app.closer.ui.paywall.PaywallScreen import app.closer.ui.questions.DailyQuestionScreen import app.closer.ui.questions.QuestionCategoryScreen @@ -287,6 +289,14 @@ fun AppNavigation( WheelHistoryScreen(onNavigate = navigateRoute) } + // Dates + composable(route = AppRoute.DATE_MATCH) { + DateMatchScreen(onNavigate = navigateRoute) + } + composable(route = AppRoute.DATE_MATCHES) { + DateMatchesScreen(onNavigate = navigateRoute) + } + // Paywall composable(route = AppRoute.PAYWALL) { PaywallScreen(onNavigate = navigateRoute) diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index 1449ad97..1b4a0c79 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -33,6 +33,8 @@ object AppRoute { const val RELATIONSHIP_SETTINGS = "relationship_settings" const val DELETE_ACCOUNT = "delete_account" 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. const val QUESTION_THREAD = @@ -75,7 +77,9 @@ object AppRoute { Definition(SUBSCRIPTION, "Subscription", "settings"), Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "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( @@ -113,6 +117,8 @@ object AppRoute { WHEEL_SESSION, WHEEL_COMPLETE, WHEEL_HISTORY, + DATE_MATCH, + DATE_MATCHES, ACCOUNT, NOTIFICATIONS, PRIVACY, diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDateMatchDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDateMatchDataSource.kt new file mode 100644 index 00000000..f85ed90a --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreDateMatchDataSource.kt @@ -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 { + 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> = 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.voidAwait() = + suspendCancellableCoroutine { 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) ?: emptyList() + ) + } +} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDateSwipeDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDateSwipeDataSource.kt new file mode 100644 index 00000000..066166ce --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreDateSwipeDataSource.kt @@ -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> { + 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 { + 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 { + val snap = swipesRef(coupleId).document(dateIdeaId).getDoc() + @Suppress("UNCHECKED_CAST") + val actions = snap.get("actions") as? Map> ?: 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.voidAwait() = + suspendCancellableCoroutine { 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> ?: 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 + ) + } +} diff --git a/app/src/main/java/app/closer/data/repository/DateIdeaSeed.kt b/app/src/main/java/app/closer/data/repository/DateIdeaSeed.kt new file mode 100644 index 00000000..21f5dbef --- /dev/null +++ b/app/src/main/java/app/closer/data/repository/DateIdeaSeed.kt @@ -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 = 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 } +} diff --git a/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt new file mode 100644 index 00000000..ef5af7d3 --- /dev/null +++ b/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt @@ -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 = DateIdeaSeed.all + + override suspend fun recordSwipe( + coupleId: String, + userId: String, + dateIdeaId: String, + action: SwipeAction + ): Result = 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> { + return swipeDataSource.observeOwnSwipes(coupleId, userId) + } + + override fun observeMatches(coupleId: String): Flow> { + return matchDataSource.observeMatches(coupleId) + } + + override suspend fun getPartnerSwipes(coupleId: String, partnerId: String): List { + return swipeDataSource.observeOwnSwipes(coupleId, partnerId, once = true).first() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/closer/di/RepositoryModule.kt b/app/src/main/java/app/closer/di/RepositoryModule.kt index 2200c8d2..a127a93a 100644 --- a/app/src/main/java/app/closer/di/RepositoryModule.kt +++ b/app/src/main/java/app/closer/di/RepositoryModule.kt @@ -4,6 +4,8 @@ import app.closer.core.billing.EntitlementChecker import app.closer.core.billing.FirestoreEntitlementChecker import app.closer.data.local.SettingsDataStore 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.FirebaseAuthRepositoryImpl import app.closer.data.repository.InviteRepositoryImpl @@ -42,6 +44,9 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindCoupleRepository(impl: CoupleRepositoryImpl): CoupleRepository + @Binds @Singleton + abstract fun bindDateMatchRepository(impl: DateMatchRepositoryImpl): DateMatchRepository + @Binds @Singleton abstract fun bindQuestionThreadRepository(impl: QuestionThreadRepositoryImpl): QuestionThreadRepository diff --git a/app/src/main/java/app/closer/domain/model/DateIdea.kt b/app/src/main/java/app/closer/domain/model/DateIdea.kt new file mode 100644 index 00000000..9e250c8e --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/DateIdea.kt @@ -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 + } + } +} diff --git a/app/src/main/java/app/closer/domain/model/DateMatch.kt b/app/src/main/java/app/closer/domain/model/DateMatch.kt new file mode 100644 index 00000000..b10c9cb6 --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/DateMatch.kt @@ -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 = emptyList() +) diff --git a/app/src/main/java/app/closer/domain/model/DateMatchSuggestion.kt b/app/src/main/java/app/closer/domain/model/DateMatchSuggestion.kt new file mode 100644 index 00000000..a3360778 --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/DateMatchSuggestion.kt @@ -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 +) diff --git a/app/src/main/java/app/closer/domain/model/DateSwipe.kt b/app/src/main/java/app/closer/domain/model/DateSwipe.kt new file mode 100644 index 00000000..94573ca7 --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/DateSwipe.kt @@ -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 + } + } +} diff --git a/app/src/main/java/app/closer/domain/repository/DateMatchRepository.kt b/app/src/main/java/app/closer/domain/repository/DateMatchRepository.kt new file mode 100644 index 00000000..06c84737 --- /dev/null +++ b/app/src/main/java/app/closer/domain/repository/DateMatchRepository.kt @@ -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 + + /** 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 + + /** 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> + + /** Observe all revealed mutual matches for the couple. */ + fun observeMatches(coupleId: String): Flow> + + /** Get partner's swipe actions for the current user to compute maybe suggestions. */ + suspend fun getPartnerSwipes(coupleId: String, partnerId: String): List +} diff --git a/app/src/main/java/app/closer/ui/dates/DateMatchScreen.kt b/app/src/main/java/app/closer/ui/dates/DateMatchScreen.kt new file mode 100644 index 00000000..5ecfe83f --- /dev/null +++ b/app/src/main/java/app/closer/ui/dates/DateMatchScreen.kt @@ -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 -> "$$$" +} diff --git a/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt b/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt new file mode 100644 index 00000000..a90eab30 --- /dev/null +++ b/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt @@ -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 = emptyList(), + val ownSwipes: Map = emptyMap(), + val matches: List = 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 = _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> = repository.observeOwnSwipes(coupleId, userId) + val matchesFlow: Flow> = 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() +} diff --git a/app/src/main/java/app/closer/ui/dates/DateMatchesScreen.kt b/app/src/main/java/app/closer/ui/dates/DateMatchesScreen.kt new file mode 100644 index 00000000..f473cb1e --- /dev/null +++ b/app/src/main/java/app/closer/ui/dates/DateMatchesScreen.kt @@ -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 -> "$$$" +} diff --git a/app/src/main/java/app/closer/ui/dates/DateMatchesViewModel.kt b/app/src/main/java/app/closer/ui/dates/DateMatchesViewModel.kt new file mode 100644 index 00000000..c1f965db --- /dev/null +++ b/app/src/main/java/app/closer/ui/dates/DateMatchesViewModel.kt @@ -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 = emptyList(), + val maybeMatches: List = 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 = _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> = repository.observeMatches(coupleId) + val partnerSwipesFlow: Flow> = repository.observeOwnSwipes(coupleId, partnerId) + val ownSwipesFlow: Flow> = 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() +} diff --git a/docs/date-planning-roadmap.md b/docs/date-planning-roadmap.md new file mode 100644 index 00000000..91c3a0b0 --- /dev/null +++ b/docs/date-planning-roadmap.md @@ -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**. \ No newline at end of file diff --git a/firestore.rules b/firestore.rules index e1b2561e..841f653c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -43,6 +43,10 @@ service cloud.firestore { return fields.every(f => resource.data[f] == request.resource.data[f]); } + function isValidSwipeAction(action) { + return action == 'love' || action == 'maybe' || action == 'skip'; + } + // ── Users ───────────────────────────────────────────────────────────────── // Each user owns exactly their own document. // 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']); } + // ── 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 system with proper ownership, validation, and expiry checks. @@ -223,6 +236,34 @@ service cloud.firestore { && 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; + } } } }