feat: add DesireSync module with sexual_preferences questions and Room integration

This commit is contained in:
null 2026-06-17 22:23:04 -05:00
parent d21763ac41
commit 85bb8d9f69
11 changed files with 713 additions and 2 deletions

1
.gitignore vendored
View File

@ -43,3 +43,4 @@ SecurityReport.md
# Firebase config (contains project ID, app ID, OAuth client, API key) # Firebase config (contains project ID, app ID, OAuth client, API key)
app/google-services.json app/google-services.json
functions/node_modules/ functions/node_modules/
UI-PLAN.md

Binary file not shown.

View File

@ -21,7 +21,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
AppNavigation() AppNavigation(startDestination = app.closer.core.navigation.AppRoute.PLAY)
} }
} }
} }

View File

@ -48,6 +48,7 @@ import app.closer.ui.dates.DateBuilderScreen
import app.closer.ui.dates.BucketListScreen import app.closer.ui.dates.BucketListScreen
import app.closer.ui.paywall.PaywallScreen import app.closer.ui.paywall.PaywallScreen
import app.closer.ui.play.PlayHubScreen import app.closer.ui.play.PlayHubScreen
import app.closer.ui.desiresync.DesireSyncScreen
import app.closer.ui.howwell.HowWellScreen import app.closer.ui.howwell.HowWellScreen
import app.closer.ui.thisorthat.ThisOrThatScreen import app.closer.ui.thisorthat.ThisOrThatScreen
import app.closer.ui.questions.DailyQuestionScreen import app.closer.ui.questions.DailyQuestionScreen
@ -306,6 +307,9 @@ fun AppNavigation(
composable(route = AppRoute.HOW_WELL) { composable(route = AppRoute.HOW_WELL) {
HowWellScreen(onNavigate = navigateRoute) HowWellScreen(onNavigate = navigateRoute)
} }
composable(route = AppRoute.DESIRE_SYNC) {
DesireSyncScreen(onNavigate = navigateRoute)
}
// Dates // Dates
composable(route = AppRoute.DATE_MATCH) { composable(route = AppRoute.DATE_MATCH) {
@ -380,6 +384,7 @@ private val shellBackRoutes = setOf(
AppRoute.DATE_MATCHES, AppRoute.DATE_MATCHES,
AppRoute.THIS_OR_THAT, AppRoute.THIS_OR_THAT,
AppRoute.HOW_WELL, AppRoute.HOW_WELL,
AppRoute.DESIRE_SYNC,
AppRoute.SUBSCRIPTION, AppRoute.SUBSCRIPTION,
) )

View File

@ -40,6 +40,7 @@ object AppRoute {
const val BUCKET_LIST = "bucket_list" const val BUCKET_LIST = "bucket_list"
const val THIS_OR_THAT = "this_or_that" const val THIS_OR_THAT = "this_or_that"
const val HOW_WELL = "how_well" const val HOW_WELL = "how_well"
const val DESIRE_SYNC = "desire_sync"
// 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 =
@ -89,7 +90,8 @@ object AppRoute {
Definition(DATE_BUILDER, "Plan a Date", "dates"), Definition(DATE_BUILDER, "Plan a Date", "dates"),
Definition(BUCKET_LIST, "Our Bucket List", "dates"), Definition(BUCKET_LIST, "Our Bucket List", "dates"),
Definition(THIS_OR_THAT, "This or That", "play"), Definition(THIS_OR_THAT, "This or That", "play"),
Definition(HOW_WELL, "How Well Do You Know Me", "play") Definition(HOW_WELL, "How Well Do You Know Me", "play"),
Definition(DESIRE_SYNC, "Desire Sync", "play")
) )
val topLevelRoutes = setOf( val topLevelRoutes = setOf(

View File

@ -27,6 +27,9 @@ interface QuestionDao {
@Query("SELECT * FROM question WHERE type IN ('single_choice', 'this_or_that', 'scale') AND status = 'active'") @Query("SELECT * FROM question WHERE type IN ('single_choice', 'this_or_that', 'scale') AND status = 'active'")
suspend fun getQuestionsForPrediction(): List<QuestionEntity> suspend fun getQuestionsForPrediction(): List<QuestionEntity>
@Query("SELECT * FROM question WHERE id LIKE :pattern AND status = 'active'")
suspend fun getDesireSyncQuestions(pattern: String): List<QuestionEntity>
@Query("SELECT * FROM question WHERE is_premium = 0 AND status = 'active'") @Query("SELECT * FROM question WHERE is_premium = 0 AND status = 'active'")
suspend fun getFreeQuestions(): List<QuestionEntity> suspend fun getFreeQuestions(): List<QuestionEntity>

View File

@ -20,4 +20,6 @@ class FakeQuestionRepository : QuestionRepository {
override suspend fun getQuestionsByType(type: String): List<Question> = emptyList() override suspend fun getQuestionsByType(type: String): List<Question> = emptyList()
override suspend fun getQuestionsForPrediction(): List<Question> = emptyList() override suspend fun getQuestionsForPrediction(): List<Question> = emptyList()
override suspend fun getDesireSyncQuestions(sex: String): List<Question> = emptyList()
} }

View File

@ -46,4 +46,8 @@ class RoomQuestionRepository @Inject constructor(
override suspend fun getQuestionsForPrediction(): List<Question> { override suspend fun getQuestionsForPrediction(): List<Question> {
return questionDao.getQuestionsForPrediction().map { it.toQuestion() } return questionDao.getQuestionsForPrediction().map { it.toQuestion() }
} }
override suspend fun getDesireSyncQuestions(sex: String): List<Question> {
return questionDao.getDesireSyncQuestions("sexual_preferences_${sex}_%").map { it.toQuestion() }
}
} }

View File

@ -12,4 +12,5 @@ interface QuestionRepository {
suspend fun getQuestionCountByCategory(categoryId: String): Int suspend fun getQuestionCountByCategory(categoryId: String): Int
suspend fun getQuestionsByType(type: String): List<Question> suspend fun getQuestionsByType(type: String): List<Question>
suspend fun getQuestionsForPrediction(): List<Question> suspend fun getQuestionsForPrediction(): List<Question>
suspend fun getDesireSyncQuestions(sex: String): List<Question>
} }

View File

@ -0,0 +1,613 @@
package app.closer.ui.desiresync
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.Question
import app.closer.domain.repository.QuestionRepository
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
// ── Domain ────────────────────────────────────────────────────────────────────
data class DesirePair(
val femaleQ: Question,
val maleQ: Question
)
data class DesireMatch(
val femaleQ: Question,
val maleQ: Question,
val label: String // human-friendly topic label
)
enum class DesireSyncPhase {
LOADING, PARTNER_A_INTRO, PARTNER_A_TURN, HANDOFF,
PARTNER_B_INTRO, PARTNER_B_TURN, REVEAL
}
data class DesireSyncUiState(
val phase: DesireSyncPhase = DesireSyncPhase.LOADING,
val pairs: List<DesirePair> = emptyList(),
val currentIndex: Int = 0,
val partnerAAnswers: List<String> = emptyList(),
val partnerBAnswers: List<String> = emptyList(),
val pendingSelection: String? = null,
val matches: List<DesireMatch> = emptyList(),
val error: String? = null
)
private val POSITIVE_IDS = setOf("yes", "true")
private fun isBinaryQuestion(q: Question): Boolean {
val config = q.answerConfig as? ChoiceAnswerConfigImpl ?: return false
val ids = config.config.options.map { it.id.lowercase() }.toSet()
return ids == setOf("yes", "no") || ids == setOf("true", "false")
}
private fun topicLabel(femaleQ: Question): String =
femaleQ.text
.replace(Regex("^(Do you want him to |Do you want her to |I want him to |I want her to |I get |I like |I wish |I prefer )", RegexOption.IGNORE_CASE), "")
.replaceFirstChar { it.uppercase() }
.trimEnd('?', '.')
// ── ViewModel ─────────────────────────────────────────────────────────────────
@HiltViewModel
class DesireSyncViewModel @Inject constructor(
private val repository: QuestionRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState())
val uiState: StateFlow<DesireSyncUiState> = _uiState.asStateFlow()
init { load() }
private fun load() {
viewModelScope.launch {
val female = runCatching { repository.getDesireSyncQuestions("female") }
.onFailure { Log.w(TAG, "load female failed", it) }
.getOrElse { emptyList() }
val male = runCatching { repository.getDesireSyncQuestions("male") }
.onFailure { Log.w(TAG, "load male failed", it) }
.getOrElse { emptyList() }
val maleById = male.associateBy { it.id.replace("_male_", "_") }
val pairs = female
.filter { isBinaryQuestion(it) }
.shuffled()
.take(SESSION_SIZE)
.mapNotNull { fq ->
val key = fq.id.replace("_female_", "_")
maleById[key]?.let { mq -> DesirePair(fq, mq) }
}
_uiState.update {
it.copy(
phase = if (pairs.isEmpty()) DesireSyncPhase.LOADING else DesireSyncPhase.PARTNER_A_INTRO,
pairs = pairs,
error = if (pairs.isEmpty()) "No questions available." else null
)
}
}
}
fun startPartnerA() = _uiState.update {
it.copy(phase = DesireSyncPhase.PARTNER_A_TURN, currentIndex = 0)
}
fun select(optionId: String) {
val s = _uiState.value
if (s.pendingSelection != null) return
_uiState.update { it.copy(pendingSelection = optionId) }
viewModelScope.launch {
delay(ADVANCE_DELAY_MS)
_uiState.update { state ->
val newAnswers = state.partnerAAnswers + optionId
val next = state.currentIndex + 1
if (next >= state.pairs.size) {
state.copy(
partnerAAnswers = newAnswers,
pendingSelection = null,
phase = DesireSyncPhase.HANDOFF
)
} else {
state.copy(
partnerAAnswers = newAnswers,
pendingSelection = null,
currentIndex = next
)
}
}
}
}
fun selectB(optionId: String) {
val s = _uiState.value
if (s.pendingSelection != null) return
_uiState.update { it.copy(pendingSelection = optionId) }
viewModelScope.launch {
delay(ADVANCE_DELAY_MS)
_uiState.update { state ->
val newAnswers = state.partnerBAnswers + optionId
val next = state.currentIndex + 1
if (next >= state.pairs.size) {
val matches = computeMatches(state.pairs, state.partnerAAnswers, newAnswers)
state.copy(
partnerBAnswers = newAnswers,
pendingSelection = null,
matches = matches,
phase = DesireSyncPhase.REVEAL
)
} else {
state.copy(
partnerBAnswers = newAnswers,
pendingSelection = null,
currentIndex = next
)
}
}
}
}
fun startPartnerB() = _uiState.update {
it.copy(phase = DesireSyncPhase.PARTNER_B_TURN, currentIndex = 0, pendingSelection = null)
}
fun restart() {
_uiState.value = DesireSyncUiState()
load()
}
private fun computeMatches(
pairs: List<DesirePair>,
aAnswers: List<String>,
bAnswers: List<String>
): List<DesireMatch> = pairs.indices.mapNotNull { i ->
val a = aAnswers.getOrNull(i)?.lowercase() ?: return@mapNotNull null
val b = bAnswers.getOrNull(i)?.lowercase() ?: return@mapNotNull null
if (a in POSITIVE_IDS && b in POSITIVE_IDS) {
DesireMatch(pairs[i].femaleQ, pairs[i].maleQ, topicLabel(pairs[i].femaleQ))
} else null
}
companion object {
private const val SESSION_SIZE = 10
private const val ADVANCE_DELAY_MS = 380L
private const val TAG = "DesireSyncViewModel"
}
}
// ── Root screen ───────────────────────────────────────────────────────────────
@Composable
fun DesireSyncScreen(
onNavigate: (String) -> Unit = {},
viewModel: DesireSyncViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.background(closerBackgroundBrush())
) {
when (state.phase) {
DesireSyncPhase.LOADING -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.Romantic
)
DesireSyncPhase.PARTNER_A_INTRO -> DSIntroScreen(
playerNumber = 1,
total = state.pairs.size,
onReady = viewModel::startPartnerA
)
DesireSyncPhase.PARTNER_A_TURN -> {
val pair = state.pairs.getOrNull(state.currentIndex) ?: return@Box
DSAnswerScreen(
question = pair.femaleQ,
index = state.currentIndex,
total = state.pairs.size,
pendingSelection = state.pendingSelection,
onSelect = viewModel::select,
onQuit = { onNavigate(AppRoute.PLAY) }
)
}
DesireSyncPhase.HANDOFF -> DSHandoffScreen(onReady = viewModel::startPartnerB)
DesireSyncPhase.PARTNER_B_INTRO -> DSIntroScreen(
playerNumber = 2,
total = state.pairs.size,
onReady = viewModel::startPartnerB
)
DesireSyncPhase.PARTNER_B_TURN -> {
val pair = state.pairs.getOrNull(state.currentIndex) ?: return@Box
DSAnswerScreen(
question = pair.maleQ,
index = state.currentIndex,
total = state.pairs.size,
pendingSelection = state.pendingSelection,
onSelect = viewModel::selectB,
onQuit = { onNavigate(AppRoute.PLAY) }
)
}
DesireSyncPhase.REVEAL -> DSRevealScreen(
matches = state.matches,
total = state.pairs.size,
onPlayAgain = viewModel::restart,
onHome = { onNavigate(AppRoute.PLAY) }
)
}
}
}
// ── Phase screens ─────────────────────────────────────────────────────────────
@Composable
private fun DSIntroScreen(playerNumber: Int, total: Int, onReady: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 40.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (playerNumber == 1) "🔥" else "💜",
fontSize = 56.sp,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(20.dp))
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.Romantic.copy(alpha = 0.14f)) {
Text(
text = "Partner ${if (playerNumber == 1) "A" else "B"}",
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelLarge,
color = CloserPalette.Romantic,
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.height(16.dp))
Text(
text = if (playerNumber == 1)
"Answer $total questions honestly — just tap Yes or No.\nYour answers are private until the reveal."
else
"Your turn. Same questions, your side.\nYour answers are private until the reveal.",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
text = "Only things you both want will be shown.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(36.dp))
Button(
onClick = onReady,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.Romantic)
) { Text("I'm ready", color = Color.White) }
}
}
@Composable
private fun DSHandoffScreen(onReady: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 40.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("🤝", fontSize = 56.sp, textAlign = TextAlign.Center)
Spacer(Modifier.height(20.dp))
Text(
text = "Pass the phone!",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(10.dp))
Text(
text = "Partner A is done. Hand the phone to Partner B — keep your answers secret until the reveal.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(36.dp))
Button(
onClick = onReady,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.Romantic)
) { Text("I'm Partner B, let's go!", color = Color.White) }
}
}
@Composable
private fun DSAnswerScreen(
question: Question,
index: Int,
total: Int,
pendingSelection: String?,
onSelect: (String) -> Unit,
onQuit: () -> Unit
) {
val config = question.answerConfig as? ChoiceAnswerConfigImpl
val options = config?.config?.options?.take(2) ?: return
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = CloserPalette.Romantic.copy(alpha = 0.12f)
) {
Text(
text = "${index + 1} / $total",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = CloserPalette.Romantic,
fontWeight = FontWeight.SemiBold
)
}
TextButton(onClick = onQuit) {
Text("Quit", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
LinearProgressIndicator(
progress = { index.toFloat() / total },
modifier = Modifier.fillMaxWidth(),
color = CloserPalette.Romantic,
trackColor = CloserPalette.Romantic.copy(alpha = 0.15f)
)
Card(
modifier = Modifier.fillMaxWidth().weight(1f),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.92f)),
elevation = CardDefaults.cardElevation(8.dp)
) {
Box(modifier = Modifier.fillMaxSize().padding(28.dp), contentAlignment = Alignment.Center) {
Text(
text = question.text,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
textAlign = TextAlign.Center,
maxLines = 5,
overflow = TextOverflow.Ellipsis
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
options.forEach { option ->
val isSelected = pendingSelection == option.id
val isPositive = option.id.lowercase() in POSITIVE_IDS
val selectedColor = if (isPositive) CloserPalette.Romantic else Color(0xFF6B6B8A)
val targetColor = when {
isSelected -> selectedColor
pendingSelection != null -> MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
else -> MaterialTheme.colorScheme.surface
}
val animatedColor by animateColorAsState(targetColor, animationSpec = tween(160), label = "option")
Card(
onClick = { if (pendingSelection == null) onSelect(option.id) },
modifier = Modifier.weight(1f).height(88.dp),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = animatedColor),
elevation = CardDefaults.cardElevation(if (isSelected) 8.dp else 3.dp)
) {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = option.text,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
}
}
}
}
}
@Composable
private fun DSRevealScreen(
matches: List<DesireMatch>,
total: Int,
onPlayAgain: () -> Unit,
onHome: () -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 32.dp, bottom = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = if (matches.isEmpty()) "🤍" else "🔥",
fontSize = 56.sp,
textAlign = TextAlign.Center
)
Text(
text = if (matches.isEmpty()) "Nothing in common this round" else "${matches.size} shared desire${if (matches.size != 1) "s" else ""}",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
textAlign = TextAlign.Center
)
if (total - matches.size > 0) {
Text(
text = "${total - matches.size} answer${if (total - matches.size != 1) "s" else ""} stayed private",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
if (matches.isNotEmpty()) {
item {
Text(
text = "You both said yes to",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = CloserPalette.Romantic
)
}
items(matches) { match ->
DesireMatchCard(match)
}
} else {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(4.dp)
) {
Text(
text = "Your desires are very different this round — or maybe you're just not in the same headspace. Play again to explore more.",
modifier = Modifier.padding(20.dp),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
item {
Column(
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
onClick = onPlayAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.Romantic)
) { Text("Play again", color = Color.White) }
OutlinedButton(
onClick = onHome,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
}
}
}
}
@Composable
private fun DesireMatchCard(match: DesireMatch) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(
containerColor = CloserPalette.Romantic.copy(alpha = 0.10f)
),
elevation = CardDefaults.cardElevation(0.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("❤️", fontSize = 20.sp)
Text(
text = match.femaleQ.text,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
color = Color(0xFF3D1F2E),
modifier = Modifier.weight(1f),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}

View File

@ -108,6 +108,12 @@ private fun PlayHubContent(
) )
} }
item {
DesireSyncCard(
onClick = { onNavigate(AppRoute.DESIRE_SYNC) }
)
}
item { item {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -237,6 +243,80 @@ private fun ThisOrThatCard(
} }
} }
@Composable
private fun DesireSyncCard(
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = RoundedCornerShape(18.dp),
color = CloserPalette.Romantic.copy(alpha = 0.12f),
modifier = Modifier.size(52.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(text = "🔥", style = MaterialTheme.typography.titleMedium)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Desire Sync",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Surface(
shape = RoundedCornerShape(999.dp),
color = CloserPalette.Romantic.copy(alpha = 0.12f)
) {
Text(
text = "🔒 Premium",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = CloserPalette.Romantic,
fontWeight = FontWeight.SemiBold
)
}
}
Text(
text = "Both answer privately. Only shared desires are revealed.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
tint = CloserPalette.Romantic,
modifier = Modifier.size(18.dp)
)
}
}
}
@Composable @Composable
private fun HowWellCard( private fun HowWellCard(
onClick: () -> Unit onClick: () -> Unit