From 254652cb86cbb82dce501ce5d6e894209a923f9b Mon Sep 17 00:00:00 2001 From: null Date: Wed, 17 Jun 2026 21:34:07 -0500 Subject: [PATCH] feat: add "This or That" screen with navigation, DB query, and Play Hub card --- app/src/main/assets/database/app.db | Bin 3747840 -> 3747840 bytes .../closer/core/navigation/AppNavigation.kt | 5 + .../app/closer/core/navigation/AppRoute.kt | 4 +- .../java/app/closer/data/local/QuestionDao.kt | 3 + .../data/repository/FakeQuestionRepository.kt | 2 + .../data/repository/RoomQuestionRepository.kt | 4 + .../domain/repository/QuestionRepository.kt | 1 + .../java/app/closer/ui/play/PlayHubScreen.kt | 84 +++ .../closer/ui/thisorthat/ThisOrThatScreen.kt | 532 ++++++++++++++++++ 9 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt diff --git a/app/src/main/assets/database/app.db b/app/src/main/assets/database/app.db index a54b35bae5777e61563824d2f494468274e44513..831ddbd580f49f5bb33ad1e839bb8b6a4cfe59e3 100644 GIT binary patch delta 1326 zcmb7^Pi)dq9LHP02ZMGDbz{0A#XocCoG@VJ=(aRwan6Nc;tr-QZMQKQG^H%@U?46& zuNvZZ<>bxeB|C8OuqA4Yr>Ph9q6g0&cJ|;8oH8gg@$gBL_xt|dukZWqYvtQ_x&k~I z^05?UuTm5hpeXtyJl#*nVnLP~fA*GT5Y>bBDfbLp^BBFWOxU%|-b4E=SNFXiFj4uT z;c@zTo_=IEX@9e6+@CM7ARMHz$RZ;%a-qyX4n!P!dTI6mBi_K$) z!yAh|PJbvw;|J~Lwj3?mjQ{+J!s_t`tR8!%W;py{?MPu5*?_{<}O~jWGUr)AY_O zBSyO}J7aF-zaDBf`u{45htx&;c8y)N)%$CKH{R;Cy$EtQhWFKdwZOaP!e+$b*mgvI z1`%BQwu)6MxzGmE>qw{R1^UC5YlmL9(Vw>}JM<=^zrf$()8pY?8n6L&zyTZs5a0w{ zKo8&sJOBf*Kri40`T!qr95?}-1p0vizz+-pr-0MIP+nOMq9#F7zA0+^h^B!~%zNeGj(n4H7pJSG<~8NuWtCZm{yF}Z}vWlY8} fxq`_!CRZ_m-Y6y$n8YxNW0JsR5)=M8NmhOV1eUZD delta 4293 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 + @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 b8e7a5cf..5c7c989c 100644 --- a/app/src/main/java/app/closer/data/repository/FakeQuestionRepository.kt +++ b/app/src/main/java/app/closer/data/repository/FakeQuestionRepository.kt @@ -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 = 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 97519a9b..eecfe68f 100644 --- a/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt +++ b/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt @@ -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 { + return questionDao.getQuestionsByType(type).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 9ec840cc..4c068f05 100644 --- a/app/src/main/java/app/closer/domain/repository/QuestionRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/QuestionRepository.kt @@ -10,4 +10,5 @@ interface QuestionRepository { suspend fun getCategories(): List suspend fun getCategoryById(id: String): QuestionCategory? suspend fun getQuestionCountByCategory(categoryId: String): Int + suspend fun getQuestionsByType(type: String): List } 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 2abbe35f..85e5f6c3 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt new file mode 100644 index 00000000..b484e188 --- /dev/null +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -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 = 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 = _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 = {} + ) + } +}