feat: add DesireSync module with sexual_preferences questions and Room integration
This commit is contained in:
parent
d21763ac41
commit
85bb8d9f69
|
|
@ -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.
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue