From 85bb8d9f69ccc9fb82a14ed736827a9949cce299 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 17 Jun 2026 22:23:04 -0500 Subject: [PATCH] feat: add DesireSync module with sexual_preferences questions and Room integration --- .gitignore | 1 + app/src/main/assets/database/app.db | Bin 3747840 -> 3747840 bytes app/src/main/java/app/closer/MainActivity.kt | 2 +- .../closer/core/navigation/AppNavigation.kt | 5 + .../app/closer/core/navigation/AppRoute.kt | 4 +- .../java/app/closer/data/local/QuestionDao.kt | 3 + .../data/repository/FakeQuestionRepository.kt | 2 + .../data/repository/RoomQuestionRepository.kt | 4 + .../domain/repository/QuestionRepository.kt | 1 + .../closer/ui/desiresync/DesireSyncScreen.kt | 613 ++++++++++++++++++ .../java/app/closer/ui/play/PlayHubScreen.kt | 80 +++ 11 files changed, 713 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt diff --git a/.gitignore b/.gitignore index 41ee5d96..7bc89048 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ SecurityReport.md # Firebase config (contains project ID, app ID, OAuth client, API key) app/google-services.json functions/node_modules/ +UI-PLAN.md diff --git a/app/src/main/assets/database/app.db b/app/src/main/assets/database/app.db index 831ddbd580f49f5bb33ad1e839bb8b6a4cfe59e3..49e58262d89d1410bc166c50f121718894710db0 100644 GIT binary patch delta 88939 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 QeN~<)mtK0;)&}4I0}bk2hyVZp delta 4214 zcmZwKX>b!|90u@xkKLtBnv@=-rA=wM3N%1VDdn~zhX+Tiq6GyBcZH^$qM~jU*&;X+ z2(k*ItsGU6OPsC_A56z_{7^<1kx_9*))^d}@k{xDXsPJi(x%P(@Zp)s^UHrXn@#fW zrq;PeYDL0XNlA*rB8sB;6@^^dGpv*(omp~ZG)v>bh+a6{CVjm{{A_USVHZpDv_$l> zL1zPHJ3NuHi<#S6=GrZD$>jCw_hS1T%N->f$t+bNPP|MXR>Il`Ra(;vV%3Jss{3zec4ZfBy;4^tK&*e$1ja_H1A6PRx z$qteeic@S z(RpGbJ#5`s(Vn`H;AmNE8|G^ZwBglyVDUpA1tR3k#QXA4g zNPo*@gvc#zmcpga_q5bcN&TeMPe}b!sUMg6C*6H**V6EDH+1!nq~0j?V^TjV^&?V0 zEPf5wtfE%#81g9y(Va+dAnib^Lo$)J$C1&wWWF9}owN<<9>@9$PQ8ru64KT#GI}lC zFXGq>NY5i}L3$2pGt#CM11=vMacl$9dZcwoHAta&>R9nmj52u5oIQb<-52$w-rsCL&Eh8s9-i{Jm=_j*UYqK^luxj5G$w zphaM`5NR~hD5Q}{0i=RnWOUuMkLYDxG#qIdQa;j9q#;OyJIRRq>o5q%1|sDldCS=`hpbrlXpUW;(j* z^f8?T(@8WPm+81oC&_d?rsFl8WYZCwq?k^s>7ap8KP0+}GCshp8`;+^GwMKEz#Ui%*v_F?c z&TJ3Zm6!bUnBCaqUzETGyQ!r|{WIKN#WTkn_5?k@c`nb!8u$%@u{-v2MJX znqlXh_ZLx0zqTsA^MBuBu8X`NUlhp z$Uu=nB7;SShzu3U7a1lpTx5h@excy=a+}wC%NpvYHwQ#Uii{E&Em9~_WKVA%6Me + @Query("SELECT * FROM question WHERE id LIKE :pattern AND status = 'active'") + suspend fun getDesireSyncQuestions(pattern: String): List + @Query("SELECT * FROM question WHERE is_premium = 0 AND status = 'active'") suspend fun getFreeQuestions(): List diff --git a/app/src/main/java/app/closer/data/repository/FakeQuestionRepository.kt b/app/src/main/java/app/closer/data/repository/FakeQuestionRepository.kt index f1ffa03c..a1816a21 100644 --- a/app/src/main/java/app/closer/data/repository/FakeQuestionRepository.kt +++ b/app/src/main/java/app/closer/data/repository/FakeQuestionRepository.kt @@ -20,4 +20,6 @@ class FakeQuestionRepository : QuestionRepository { override suspend fun getQuestionsByType(type: String): List = emptyList() override suspend fun getQuestionsForPrediction(): List = emptyList() + + override suspend fun getDesireSyncQuestions(sex: String): List = emptyList() } diff --git a/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt b/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt index 7f63928a..a3167f09 100644 --- a/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt +++ b/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt @@ -46,4 +46,8 @@ class RoomQuestionRepository @Inject constructor( override suspend fun getQuestionsForPrediction(): List { return questionDao.getQuestionsForPrediction().map { it.toQuestion() } } + + override suspend fun getDesireSyncQuestions(sex: String): List { + return questionDao.getDesireSyncQuestions("sexual_preferences_${sex}_%").map { it.toQuestion() } + } } diff --git a/app/src/main/java/app/closer/domain/repository/QuestionRepository.kt b/app/src/main/java/app/closer/domain/repository/QuestionRepository.kt index f673cd80..4ba833e8 100644 --- a/app/src/main/java/app/closer/domain/repository/QuestionRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/QuestionRepository.kt @@ -12,4 +12,5 @@ interface QuestionRepository { suspend fun getQuestionCountByCategory(categoryId: String): Int suspend fun getQuestionsByType(type: String): List suspend fun getQuestionsForPrediction(): List + suspend fun getDesireSyncQuestions(sex: String): List } diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt new file mode 100644 index 00000000..1dedad5f --- /dev/null +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -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 = emptyList(), + val currentIndex: Int = 0, + val partnerAAnswers: List = emptyList(), + val partnerBAnswers: List = emptyList(), + val pendingSelection: String? = null, + val matches: List = 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 = _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, + aAnswers: List, + bAnswers: List + ): List = 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, + 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 + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt index c3a21a37..f8573901 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt @@ -108,6 +108,12 @@ private fun PlayHubContent( ) } + item { + DesireSyncCard( + onClick = { onNavigate(AppRoute.DESIRE_SYNC) } + ) + } + item { Row( 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 private fun HowWellCard( onClick: () -> Unit