diff --git a/app/src/main/assets/database/app.db b/app/src/main/assets/database/app.db index a54b35ba..831ddbd5 100644 Binary files a/app/src/main/assets/database/app.db and b/app/src/main/assets/database/app.db differ diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 6f65e504..5c68037c 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -48,6 +48,7 @@ import app.closer.ui.dates.DateBuilderScreen import app.closer.ui.dates.BucketListScreen import app.closer.ui.paywall.PaywallScreen import app.closer.ui.play.PlayHubScreen +import app.closer.ui.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 diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index c4fd4854..144101a3 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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( diff --git a/app/src/main/java/app/closer/data/local/QuestionDao.kt b/app/src/main/java/app/closer/data/local/QuestionDao.kt index 0a4e3483..4b4398e7 100644 --- a/app/src/main/java/app/closer/data/local/QuestionDao.kt +++ b/app/src/main/java/app/closer/data/local/QuestionDao.kt @@ -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 + @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 = {} + ) + } +}