feat: add "This or That" screen with navigation, DB query, and Play Hub card
This commit is contained in:
parent
84390a48fc
commit
254652cb86
Binary file not shown.
|
|
@ -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.thisorthat.ThisOrThatScreen
|
||||||
import app.closer.ui.questions.DailyQuestionScreen
|
import app.closer.ui.questions.DailyQuestionScreen
|
||||||
import app.closer.ui.questions.QuestionCategoryScreen
|
import app.closer.ui.questions.QuestionCategoryScreen
|
||||||
import app.closer.ui.questions.QuestionComposerScreen
|
import app.closer.ui.questions.QuestionComposerScreen
|
||||||
|
|
@ -298,6 +299,9 @@ fun AppNavigation(
|
||||||
composable(route = AppRoute.WHEEL_HISTORY) {
|
composable(route = AppRoute.WHEEL_HISTORY) {
|
||||||
WheelHistoryScreen(onNavigate = navigateRoute)
|
WheelHistoryScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
|
composable(route = AppRoute.THIS_OR_THAT) {
|
||||||
|
ThisOrThatScreen(onNavigate = navigateRoute)
|
||||||
|
}
|
||||||
|
|
||||||
// Dates
|
// Dates
|
||||||
composable(route = AppRoute.DATE_MATCH) {
|
composable(route = AppRoute.DATE_MATCH) {
|
||||||
|
|
@ -373,6 +377,7 @@ private val shellBackRoutes = setOf(
|
||||||
AppRoute.DATE_MATCHES,
|
AppRoute.DATE_MATCHES,
|
||||||
AppRoute.DATE_BUILDER,
|
AppRoute.DATE_BUILDER,
|
||||||
AppRoute.BUCKET_LIST,
|
AppRoute.BUCKET_LIST,
|
||||||
|
AppRoute.THIS_OR_THAT,
|
||||||
AppRoute.ACCOUNT,
|
AppRoute.ACCOUNT,
|
||||||
AppRoute.SUBSCRIPTION,
|
AppRoute.SUBSCRIPTION,
|
||||||
AppRoute.PAYWALL
|
AppRoute.PAYWALL
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ object AppRoute {
|
||||||
const val DATE_MATCHES = "date_matches"
|
const val DATE_MATCHES = "date_matches"
|
||||||
const val DATE_BUILDER = "date_builder"
|
const val DATE_BUILDER = "date_builder"
|
||||||
const val BUCKET_LIST = "bucket_list"
|
const val BUCKET_LIST = "bucket_list"
|
||||||
|
const val THIS_OR_THAT = "this_or_that"
|
||||||
|
|
||||||
// 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 =
|
||||||
|
|
@ -85,7 +86,8 @@ object AppRoute {
|
||||||
Definition(DATE_MATCH, "Date Match", "dates"),
|
Definition(DATE_MATCH, "Date Match", "dates"),
|
||||||
Definition(DATE_MATCHES, "Matches", "dates"),
|
Definition(DATE_MATCHES, "Matches", "dates"),
|
||||||
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")
|
||||||
)
|
)
|
||||||
|
|
||||||
val topLevelRoutes = setOf(
|
val topLevelRoutes = setOf(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ interface QuestionDao {
|
||||||
@Query("SELECT COUNT(*) FROM question WHERE category_id = :categoryId AND status = 'active'")
|
@Query("SELECT COUNT(*) FROM question WHERE category_id = :categoryId AND status = 'active'")
|
||||||
suspend fun getQuestionCountByCategory(categoryId: String): Int
|
suspend fun getQuestionCountByCategory(categoryId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT * FROM question WHERE type = :type AND status = 'active'")
|
||||||
|
suspend fun getQuestionsByType(type: 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,6 @@ class FakeQuestionRepository : QuestionRepository {
|
||||||
override suspend fun getCategoryById(id: String): QuestionCategory? = null
|
override suspend fun getCategoryById(id: String): QuestionCategory? = null
|
||||||
|
|
||||||
override suspend fun getQuestionCountByCategory(categoryId: String): Int = 0
|
override suspend fun getQuestionCountByCategory(categoryId: String): Int = 0
|
||||||
|
|
||||||
|
override suspend fun getQuestionsByType(type: String): List<Question> = emptyList()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,4 +38,8 @@ class RoomQuestionRepository @Inject constructor(
|
||||||
override suspend fun getQuestionCountByCategory(categoryId: String): Int {
|
override suspend fun getQuestionCountByCategory(categoryId: String): Int {
|
||||||
return questionDao.getQuestionCountByCategory(categoryId)
|
return questionDao.getQuestionCountByCategory(categoryId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getQuestionsByType(type: String): List<Question> {
|
||||||
|
return questionDao.getQuestionsByType(type).map { it.toQuestion() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,5 @@ interface QuestionRepository {
|
||||||
suspend fun getCategories(): List<QuestionCategory>
|
suspend fun getCategories(): List<QuestionCategory>
|
||||||
suspend fun getCategoryById(id: String): QuestionCategory?
|
suspend fun getCategoryById(id: String): QuestionCategory?
|
||||||
suspend fun getQuestionCountByCategory(categoryId: String): Int
|
suspend fun getQuestionCountByCategory(categoryId: String): Int
|
||||||
|
suspend fun getQuestionsByType(type: String): List<Question>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,12 @@ private fun PlayHubContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
ThisOrThatCard(
|
||||||
|
onClick = { onNavigate(AppRoute.THIS_OR_THAT) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -147,6 +153,84 @@ private fun PlayHubContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThisOrThatCard(
|
||||||
|
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.PinkMist,
|
||||||
|
modifier = Modifier.size(52.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = "A/B",
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = CloserPalette.PinkAccentDeep
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "This or That",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = CloserPalette.PinkMist
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "10 prompts",
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = CloserPalette.PinkAccentDeep,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Rapid-fire A or B choices. See where you and your partner land.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = CloserPalette.PinkAccentDeep,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FeaturedPlayCard(
|
private fun FeaturedPlayCard(
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,532 @@
|
||||||
|
package app.closer.ui.thisorthat
|
||||||
|
|
||||||
|
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.layout.width
|
||||||
|
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.Divider
|
||||||
|
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.tooling.preview.Preview
|
||||||
|
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.ChoiceOption
|
||||||
|
import app.closer.domain.model.Question
|
||||||
|
import app.closer.domain.model.ThisOrThatAnswerConfig
|
||||||
|
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
|
||||||
|
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
|
||||||
|
|
||||||
|
// ── ViewModel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class ThisOrThatUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val questions: List<Question> = emptyList(),
|
||||||
|
val currentIndex: Int = 0,
|
||||||
|
val pendingSelection: String? = null,
|
||||||
|
val aCount: Int = 0,
|
||||||
|
val bCount: Int = 0,
|
||||||
|
val isComplete: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ThisOrThatViewModel @Inject constructor(
|
||||||
|
private val repository: QuestionRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ThisOrThatUiState())
|
||||||
|
val uiState: StateFlow<ThisOrThatUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init { load() }
|
||||||
|
|
||||||
|
private fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val questions = runCatching {
|
||||||
|
repository.getQuestionsByType("this_or_that").shuffled().take(SESSION_SIZE)
|
||||||
|
}
|
||||||
|
.onFailure { Log.w(TAG, "Failed to load this_or_that questions", it) }
|
||||||
|
.getOrElse { emptyList() }
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
questions = questions,
|
||||||
|
error = if (questions.isEmpty()) "No questions available." else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(optionId: String) {
|
||||||
|
val s = _uiState.value
|
||||||
|
if (s.pendingSelection != null || s.isComplete || s.isLoading) return
|
||||||
|
val config = s.questions.getOrNull(s.currentIndex)
|
||||||
|
?.answerConfig as? ThisOrThatAnswerConfigImpl ?: return
|
||||||
|
val isA = config.config.optionA.id == optionId
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
pendingSelection = optionId,
|
||||||
|
aCount = if (isA) it.aCount + 1 else it.aCount,
|
||||||
|
bCount = if (!isA) it.bCount + 1 else it.bCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
delay(420)
|
||||||
|
val next = s.currentIndex + 1
|
||||||
|
_uiState.update {
|
||||||
|
if (next >= it.questions.size)
|
||||||
|
it.copy(pendingSelection = null, isComplete = true)
|
||||||
|
else
|
||||||
|
it.copy(pendingSelection = null, currentIndex = next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restart() {
|
||||||
|
_uiState.value = ThisOrThatUiState()
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SESSION_SIZE = 10
|
||||||
|
private const val TAG = "ThisOrThatViewModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Screen ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ThisOrThatScreen(
|
||||||
|
onNavigate: (String) -> Unit = {},
|
||||||
|
viewModel: ThisOrThatViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(closerBackgroundBrush())
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.isLoading -> CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
color = CloserPalette.PurpleDeep
|
||||||
|
)
|
||||||
|
state.error != null -> ErrorState(
|
||||||
|
message = state.error!!,
|
||||||
|
onBack = { onNavigate(AppRoute.PLAY) }
|
||||||
|
)
|
||||||
|
state.isComplete -> ThisOrThatComplete(
|
||||||
|
aCount = state.aCount,
|
||||||
|
bCount = state.bCount,
|
||||||
|
total = state.questions.size,
|
||||||
|
onPlayAgain = viewModel::restart,
|
||||||
|
onHome = { onNavigate(AppRoute.PLAY) }
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
val question = state.questions[state.currentIndex]
|
||||||
|
val config = question.answerConfig as? ThisOrThatAnswerConfigImpl
|
||||||
|
ThisOrThatContent(
|
||||||
|
question = question,
|
||||||
|
config = config,
|
||||||
|
currentIndex = state.currentIndex,
|
||||||
|
total = state.questions.size,
|
||||||
|
pendingSelection = state.pendingSelection,
|
||||||
|
onSelect = viewModel::select,
|
||||||
|
onBack = { onNavigate(AppRoute.PLAY) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThisOrThatContent(
|
||||||
|
question: Question,
|
||||||
|
config: ThisOrThatAnswerConfigImpl?,
|
||||||
|
currentIndex: Int,
|
||||||
|
total: Int,
|
||||||
|
pendingSelection: String?,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = CloserPalette.PurpleMist
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${currentIndex + 1} / $total",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = CloserPalette.PurpleDeep,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onBack) {
|
||||||
|
Text("Quit", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.9f)),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = CloserPalette.PurpleMist
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "This or That",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = CloserPalette.PurpleDeep,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = question.text,
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 5,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config != null) {
|
||||||
|
OptionCard(
|
||||||
|
text = config.config.optionA.text,
|
||||||
|
label = "A",
|
||||||
|
optionId = config.config.optionA.id,
|
||||||
|
pendingSelection = pendingSelection,
|
||||||
|
accentColor = CloserPalette.PurpleDeep,
|
||||||
|
onSelect = onSelect
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "or",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
OptionCard(
|
||||||
|
text = config.config.optionB.text,
|
||||||
|
label = "B",
|
||||||
|
optionId = config.config.optionB.id,
|
||||||
|
pendingSelection = pendingSelection,
|
||||||
|
accentColor = CloserPalette.PinkAccentDeep,
|
||||||
|
onSelect = onSelect
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OptionCard(
|
||||||
|
text: String,
|
||||||
|
label: String,
|
||||||
|
optionId: String,
|
||||||
|
pendingSelection: String?,
|
||||||
|
accentColor: Color,
|
||||||
|
onSelect: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val isSelected = pendingSelection == optionId
|
||||||
|
val isOtherSelected = pendingSelection != null && !isSelected
|
||||||
|
|
||||||
|
val background by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isSelected -> accentColor
|
||||||
|
isOtherSelected -> MaterialTheme.colorScheme.surface.copy(alpha = 0.45f)
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
animationSpec = tween(180),
|
||||||
|
label = "bg_$optionId"
|
||||||
|
)
|
||||||
|
val contentColor by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isSelected -> Color.White
|
||||||
|
isOtherSelected -> Color(0xFFCCC0D5)
|
||||||
|
else -> accentColor
|
||||||
|
},
|
||||||
|
animationSpec = tween(180),
|
||||||
|
label = "fg_$optionId"
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = { if (pendingSelection == null) onSelect(optionId) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 72.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = background),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 10.dp else 4.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 18.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = if (isSelected) Color.White.copy(alpha = 0.22f) else accentColor.copy(alpha = 0.12f),
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = contentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = contentColor,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThisOrThatComplete(
|
||||||
|
aCount: Int,
|
||||||
|
bCount: Int,
|
||||||
|
total: Int,
|
||||||
|
onPlayAgain: () -> Unit,
|
||||||
|
onHome: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 28.dp, vertical = 40.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
Text("✓", fontSize = 64.sp, color = CloserPalette.PurpleDeep)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "All done!",
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF261D2E),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "You went through $total prompts.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color(0xFF5A5060),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.88f)),
|
||||||
|
elevation = CardDefaults.cardElevation(6.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TallyItem(label = "A", count = aCount, color = CloserPalette.PurpleDeep)
|
||||||
|
Divider(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(48.dp)
|
||||||
|
.width(1.dp),
|
||||||
|
color = Color(0xFFE8E0F0)
|
||||||
|
)
|
||||||
|
TallyItem(label = "B", count = bCount, color = CloserPalette.PinkAccentDeep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onPlayAgain,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
|
||||||
|
) {
|
||||||
|
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 TallyItem(label: String, count: Int, color: Color) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$count",
|
||||||
|
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "picked $label",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(0xFF5A5060)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorState(message: String, onBack: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.padding(28.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
OutlinedButton(onClick = onBack) {
|
||||||
|
Text("Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preview ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ThisOrThatContentPreview() {
|
||||||
|
val config = ThisOrThatAnswerConfigImpl(
|
||||||
|
config = ThisOrThatAnswerConfig(
|
||||||
|
optionA = ChoiceOption(id = "game_night", text = "Game night"),
|
||||||
|
optionB = ChoiceOption(id = "movie_night", text = "Movie night")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val question = Question(
|
||||||
|
id = "1",
|
||||||
|
text = "Game night or movie night?",
|
||||||
|
category = "fun",
|
||||||
|
answerConfig = config
|
||||||
|
)
|
||||||
|
Box(Modifier.background(Color(0xFFFFFBFE))) {
|
||||||
|
ThisOrThatContent(
|
||||||
|
question = question,
|
||||||
|
config = config,
|
||||||
|
currentIndex = 2,
|
||||||
|
total = 10,
|
||||||
|
pendingSelection = null,
|
||||||
|
onSelect = {},
|
||||||
|
onBack = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue