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 831ddbd5..49e58262 100644 Binary files a/app/src/main/assets/database/app.db and b/app/src/main/assets/database/app.db differ diff --git a/app/src/main/java/app/closer/MainActivity.kt b/app/src/main/java/app/closer/MainActivity.kt index f3232d78..a2482440 100644 --- a/app/src/main/java/app/closer/MainActivity.kt +++ b/app/src/main/java/app/closer/MainActivity.kt @@ -21,7 +21,7 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - AppNavigation() + AppNavigation(startDestination = app.closer.core.navigation.AppRoute.PLAY) } } } 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 e46a1e24..880b977a 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -48,6 +48,7 @@ import app.closer.ui.dates.DateBuilderScreen import app.closer.ui.dates.BucketListScreen import app.closer.ui.paywall.PaywallScreen import app.closer.ui.play.PlayHubScreen +import app.closer.ui.desiresync.DesireSyncScreen import app.closer.ui.howwell.HowWellScreen import app.closer.ui.thisorthat.ThisOrThatScreen import app.closer.ui.questions.DailyQuestionScreen @@ -306,6 +307,9 @@ fun AppNavigation( composable(route = AppRoute.HOW_WELL) { HowWellScreen(onNavigate = navigateRoute) } + composable(route = AppRoute.DESIRE_SYNC) { + DesireSyncScreen(onNavigate = navigateRoute) + } // Dates composable(route = AppRoute.DATE_MATCH) { @@ -380,6 +384,7 @@ private val shellBackRoutes = setOf( AppRoute.DATE_MATCHES, AppRoute.THIS_OR_THAT, AppRoute.HOW_WELL, + AppRoute.DESIRE_SYNC, AppRoute.SUBSCRIPTION, ) 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 860a7ae4..01349726 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -40,6 +40,7 @@ object AppRoute { const val BUCKET_LIST = "bucket_list" const val THIS_OR_THAT = "this_or_that" const val HOW_WELL = "how_well" + const val DESIRE_SYNC = "desire_sync" // Question thread: coupleId and questionId are required; prevId and nextId are optional. const val QUESTION_THREAD = @@ -89,7 +90,8 @@ object AppRoute { Definition(DATE_BUILDER, "Plan a Date", "dates"), Definition(BUCKET_LIST, "Our Bucket List", "dates"), 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( diff --git a/app/src/main/java/app/closer/data/local/QuestionDao.kt b/app/src/main/java/app/closer/data/local/QuestionDao.kt index 408f0e17..41e4bdf9 100644 --- a/app/src/main/java/app/closer/data/local/QuestionDao.kt +++ b/app/src/main/java/app/closer/data/local/QuestionDao.kt @@ -27,6 +27,9 @@ interface QuestionDao { @Query("SELECT * FROM question WHERE type IN ('single_choice', 'this_or_that', 'scale') AND status = 'active'") suspend fun getQuestionsForPrediction(): List + @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