feat: add "This or That" screen with navigation, DB query, and Play Hub card

This commit is contained in:
null 2026-06-17 21:34:07 -05:00
parent 84390a48fc
commit 254652cb86
9 changed files with 634 additions and 1 deletions

Binary file not shown.

View File

@ -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.thisorthat.ThisOrThatScreen
import app.closer.ui.questions.DailyQuestionScreen
import app.closer.ui.questions.QuestionCategoryScreen
import app.closer.ui.questions.QuestionComposerScreen
@ -298,6 +299,9 @@ fun AppNavigation(
composable(route = AppRoute.WHEEL_HISTORY) {
WheelHistoryScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.THIS_OR_THAT) {
ThisOrThatScreen(onNavigate = navigateRoute)
}
// Dates
composable(route = AppRoute.DATE_MATCH) {
@ -373,6 +377,7 @@ private val shellBackRoutes = setOf(
AppRoute.DATE_MATCHES,
AppRoute.DATE_BUILDER,
AppRoute.BUCKET_LIST,
AppRoute.THIS_OR_THAT,
AppRoute.ACCOUNT,
AppRoute.SUBSCRIPTION,
AppRoute.PAYWALL

View File

@ -38,6 +38,7 @@ object AppRoute {
const val DATE_MATCHES = "date_matches"
const val DATE_BUILDER = "date_builder"
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.
const val QUESTION_THREAD =
@ -85,7 +86,8 @@ object AppRoute {
Definition(DATE_MATCH, "Date Match", "dates"),
Definition(DATE_MATCHES, "Matches", "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(

View File

@ -21,6 +21,9 @@ interface QuestionDao {
@Query("SELECT COUNT(*) FROM question WHERE category_id = :categoryId AND status = 'active'")
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'")
suspend fun getFreeQuestions(): List<QuestionEntity>

View File

@ -16,4 +16,6 @@ class FakeQuestionRepository : QuestionRepository {
override suspend fun getCategoryById(id: String): QuestionCategory? = null
override suspend fun getQuestionCountByCategory(categoryId: String): Int = 0
override suspend fun getQuestionsByType(type: String): List<Question> = emptyList()
}

View File

@ -38,4 +38,8 @@ class RoomQuestionRepository @Inject constructor(
override suspend fun getQuestionCountByCategory(categoryId: String): Int {
return questionDao.getQuestionCountByCategory(categoryId)
}
override suspend fun getQuestionsByType(type: String): List<Question> {
return questionDao.getQuestionsByType(type).map { it.toQuestion() }
}
}

View File

@ -10,4 +10,5 @@ interface QuestionRepository {
suspend fun getCategories(): List<QuestionCategory>
suspend fun getCategoryById(id: String): QuestionCategory?
suspend fun getQuestionCountByCategory(categoryId: String): Int
suspend fun getQuestionsByType(type: String): List<Question>
}

View File

@ -96,6 +96,12 @@ private fun PlayHubContent(
)
}
item {
ThisOrThatCard(
onClick = { onNavigate(AppRoute.THIS_OR_THAT) }
)
}
item {
Row(
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
private fun FeaturedPlayCard(
onClick: () -> Unit

View File

@ -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 = {}
)
}
}