feat: add SessionLength model, update game screens with session length awareness

This commit is contained in:
null 2026-06-19 02:50:58 -05:00
parent 0e0a33a6dd
commit 927e930b79
6 changed files with 300 additions and 19 deletions

View File

@ -0,0 +1,7 @@
package app.closer.domain.model
enum class SessionLength(val label: String, val count: Int) {
SHORT("5 · Quick", 5),
STANDARD("10 · Standard", 10),
LONG("15 · Long", 15)
}

View File

@ -5,6 +5,7 @@ import app.closer.ui.theme.closerCardColor
import android.util.Log import android.util.Log
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -55,11 +56,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.data.remote.FirestoreDesireSyncDataSource import app.closer.data.remote.FirestoreDesireSyncDataSource
import android.content.Context
import android.provider.Settings
import app.closer.domain.model.ChoiceAnswerConfigImpl import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.model.SessionLength
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import dagger.hilt.android.qualifiers.ApplicationContext
import app.closer.ui.components.StatusGlyph import app.closer.ui.components.StatusGlyph
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
@ -80,7 +85,7 @@ data class DesireMatch(
val question: Question val question: Question
) )
enum class DesireSyncPhase { LOADING, INTRO, ANSWER, WAITING, REVEAL, ERROR } enum class DesireSyncPhase { LOADING, SETUP, INTRO, ANSWER, WAITING, REVEAL, ERROR }
data class DesireSyncUiState( data class DesireSyncUiState(
val phase: DesireSyncPhase = DesireSyncPhase.LOADING, val phase: DesireSyncPhase = DesireSyncPhase.LOADING,
@ -90,6 +95,7 @@ data class DesireSyncUiState(
val myAnswers: List<String> = emptyList(), val myAnswers: List<String> = emptyList(),
val partnerName: String = "Your partner", val partnerName: String = "Your partner",
val matches: List<DesireMatch> = emptyList(), val matches: List<DesireMatch> = emptyList(),
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null
) )
@ -106,6 +112,7 @@ private fun isBinaryQuestion(q: Question): Boolean {
@HiltViewModel @HiltViewModel
class DesireSyncViewModel @Inject constructor( class DesireSyncViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager, private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource private val dataSource: FirestoreDesireSyncDataSource
@ -152,14 +159,24 @@ class DesireSyncViewModel @Inject constructor(
// A different game is already in progress — respect the one-game lock. // A different game is already in progress — respect the one-game lock.
_uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) } _uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) }
else -> else ->
createSession(uid) _uiState.update { it.copy(phase = DesireSyncPhase.SETUP) }
} }
} }
} }
fun setLength(len: SessionLength) {
_uiState.update { it.copy(selectedLength = len) }
}
fun startGame() {
val uid = userId ?: return
_uiState.update { it.copy(phase = DesireSyncPhase.LOADING) }
viewModelScope.launch { createSession(uid) }
}
/** First partner: pick the neutral question set and open the shared session. */ /** First partner: pick the neutral question set and open the shared session. */
private suspend fun createSession(uid: String) { private suspend fun createSession(uid: String) {
val questions = loadNeutralQuestions().shuffled().take(SESSION_SIZE) val questions = loadNeutralQuestions().shuffled().take(_uiState.value.selectedLength.count)
if (questions.isEmpty()) return fail("No questions available.") if (questions.isEmpty()) return fail("No questions available.")
val startResult = runCatching { val startResult = runCatching {
@ -214,7 +231,10 @@ class DesireSyncViewModel @Inject constructor(
if (s.pendingSelection != null || s.phase != DesireSyncPhase.ANSWER) return if (s.pendingSelection != null || s.phase != DesireSyncPhase.ANSWER) return
_uiState.update { it.copy(pendingSelection = optionId) } _uiState.update { it.copy(pendingSelection = optionId) }
viewModelScope.launch { viewModelScope.launch {
delay(ADVANCE_DELAY_MS) val reduceMotion = Settings.Global.getFloat(
context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f
) == 0f
delay(if (reduceMotion) 0L else ADVANCE_DELAY_MS)
val answers = _uiState.value.myAnswers + optionId val answers = _uiState.value.myAnswers + optionId
val next = _uiState.value.currentIndex + 1 val next = _uiState.value.currentIndex + 1
if (next >= _uiState.value.questions.size) { if (next >= _uiState.value.questions.size) {
@ -322,7 +342,6 @@ class DesireSyncViewModel @Inject constructor(
} }
companion object { companion object {
private const val SESSION_SIZE = 10
private const val ADVANCE_DELAY_MS = 380L private const val ADVANCE_DELAY_MS = 380L
private const val TAG = "DesireSyncViewModel" private const val TAG = "DesireSyncViewModel"
} }
@ -358,6 +377,12 @@ fun DesireSyncScreen(
message = state.error ?: "Something went wrong.", message = state.error ?: "Something went wrong.",
onBack = viewModel::quit onBack = viewModel::quit
) )
DesireSyncPhase.SETUP -> DSSetupScreen(
selectedLength = state.selectedLength,
onLengthSelected = viewModel::setLength,
onStart = viewModel::startGame,
onBack = viewModel::quit
)
DesireSyncPhase.INTRO -> DSIntroScreen( DesireSyncPhase.INTRO -> DSIntroScreen(
total = state.questions.size, total = state.questions.size,
onReady = viewModel::startAnswering onReady = viewModel::startAnswering
@ -391,6 +416,72 @@ fun DesireSyncScreen(
// ── Phase screens ───────────────────────────────────────────────────────────── // ── Phase screens ─────────────────────────────────────────────────────────────
@Composable
private fun DSSetupScreen(
selectedLength: SessionLength,
onLengthSelected: (SessionLength) -> Unit,
onStart: () -> Unit,
onBack: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 24.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "How long?",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold)
)
TextButton(onClick = onBack) {
Text("Back", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Text(
text = "Choose a round length. Your partner will answer the same questions.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SessionLength.values().forEach { len ->
Surface(
onClick = { onLengthSelected(len) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
color = if (len == selectedLength) CloserPalette.Romantic else Color.Transparent,
border = if (len != selectedLength)
BorderStroke(1.dp, CloserPalette.Romantic.copy(alpha = 0.4f))
else null
) {
Text(
text = len.label,
modifier = Modifier.padding(vertical = 10.dp),
style = MaterialTheme.typography.labelMedium,
color = if (len == selectedLength) Color.White else CloserPalette.Romantic,
textAlign = TextAlign.Center
)
}
}
}
Spacer(Modifier.weight(1f))
Button(
onClick = onStart,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.Romantic)
) {
Text("Create session")
}
}
}
@Composable @Composable
private fun DSIntroScreen(total: Int, onReady: () -> Unit) { private fun DSIntroScreen(total: Int, onReady: () -> Unit) {
Column( Column(

View File

@ -3,6 +3,7 @@ package app.closer.ui.howwell
import app.closer.domain.model.GameType import app.closer.domain.model.GameType
import app.closer.ui.theme.closerCardColor import app.closer.ui.theme.closerCardColor
import android.util.Log import android.util.Log
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -57,12 +58,15 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import android.content.Context
import app.closer.domain.model.AnswerConfig import app.closer.domain.model.AnswerConfig
import app.closer.domain.model.ChoiceAnswerConfigImpl import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.ChoiceOption import app.closer.domain.model.ChoiceOption
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.model.ScaleAnswerConfigImpl import app.closer.domain.model.ScaleAnswerConfigImpl
import app.closer.domain.model.SessionLength
import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import dagger.hilt.android.qualifiers.ApplicationContext
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.repository.QuestionSessionRepository
import app.closer.data.remote.FirestoreHowWellDataSource import app.closer.data.remote.FirestoreHowWellDataSource
@ -122,7 +126,7 @@ data class HowWellResult(
val isClose: Boolean val isClose: Boolean
) )
enum class HowWellPhase { LOADING, INTRO, ANSWER, WAITING, COMPLETE, ERROR } enum class HowWellPhase { LOADING, SETUP, INTRO, ANSWER, WAITING, COMPLETE, ERROR }
data class HowWellUiState( data class HowWellUiState(
val phase: HowWellPhase = HowWellPhase.LOADING, val phase: HowWellPhase = HowWellPhase.LOADING,
@ -135,6 +139,7 @@ data class HowWellUiState(
val partnerName: String = "Your partner", val partnerName: String = "Your partner",
val results: List<HowWellResult> = emptyList(), val results: List<HowWellResult> = emptyList(),
val score: Int = 0, val score: Int = 0,
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null
) )
@ -143,6 +148,7 @@ data class HowWellUiState(
@HiltViewModel @HiltViewModel
class HowWellViewModel @Inject constructor( class HowWellViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager, private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreHowWellDataSource private val dataSource: FirestoreHowWellDataSource
@ -188,18 +194,28 @@ class HowWellViewModel @Inject constructor(
// A different game is already in progress — respect the one-game lock. // A different game is already in progress — respect the one-game lock.
_uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) } _uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) }
else -> else ->
createSession(uid) _uiState.update { it.copy(phase = HowWellPhase.SETUP) }
} }
} }
} }
fun setLength(len: SessionLength) {
_uiState.update { it.copy(selectedLength = len) }
}
fun startGame() {
val uid = userId ?: return
_uiState.update { it.copy(phase = HowWellPhase.LOADING) }
viewModelScope.launch { createSession(uid) }
}
/** First partner becomes the subject: they answer about themselves. */ /** First partner becomes the subject: they answer about themselves. */
private suspend fun createSession(uid: String) { private suspend fun createSession(uid: String) {
val questions = runCatching { repository.getQuestionsForPrediction() } val questions = runCatching { repository.getQuestionsForPrediction() }
.onFailure { Log.w(TAG, "Failed to load prediction questions", it) } .onFailure { Log.w(TAG, "Failed to load prediction questions", it) }
.getOrElse { emptyList() } .getOrElse { emptyList() }
.shuffled() .shuffled()
.take(SESSION_SIZE) .take(_uiState.value.selectedLength.count)
if (questions.isEmpty()) return fail("No questions available.") if (questions.isEmpty()) return fail("No questions available.")
val startResult = runCatching { val startResult = runCatching {
@ -371,7 +387,6 @@ class HowWellViewModel @Inject constructor(
} }
companion object { companion object {
const val SESSION_SIZE = 10
private const val TAG = "HowWellViewModel" private const val TAG = "HowWellViewModel"
} }
} }
@ -406,6 +421,12 @@ fun HowWellScreen(
message = state.error ?: "Something went wrong.", message = state.error ?: "Something went wrong.",
onBack = viewModel::quit onBack = viewModel::quit
) )
HowWellPhase.SETUP -> HWSetupScreen(
selectedLength = state.selectedLength,
onLengthSelected = viewModel::setLength,
onStart = viewModel::startGame,
onBack = viewModel::quit
)
HowWellPhase.INTRO -> PlayerIntroScreen( HowWellPhase.INTRO -> PlayerIntroScreen(
amSubject = state.amSubject, amSubject = state.amSubject,
partnerName = state.partnerName, partnerName = state.partnerName,
@ -449,6 +470,72 @@ fun HowWellScreen(
// ── Phase screens ───────────────────────────────────────────────────────────── // ── Phase screens ─────────────────────────────────────────────────────────────
@Composable
private fun HWSetupScreen(
selectedLength: SessionLength,
onLengthSelected: (SessionLength) -> Unit,
onStart: () -> Unit,
onBack: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 24.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "How long?",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold)
)
TextButton(onClick = onBack) {
Text("Back", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Text(
text = "Choose a round length. You'll answer about yourself and your partner will try to guess.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SessionLength.values().forEach { len ->
Surface(
onClick = { onLengthSelected(len) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
color = if (len == selectedLength) CloserPalette.PurpleDeep else Color.Transparent,
border = if (len != selectedLength)
BorderStroke(1.dp, CloserPalette.PurpleDeep.copy(alpha = 0.4f))
else null
) {
Text(
text = len.label,
modifier = Modifier.padding(vertical = 10.dp),
style = MaterialTheme.typography.labelMedium,
color = if (len == selectedLength) Color.White else CloserPalette.PurpleDeep,
textAlign = TextAlign.Center
)
}
}
}
Spacer(Modifier.weight(1f))
Button(
onClick = onStart,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) {
Text("Create session")
}
}
}
@Composable @Composable
private fun PlayerIntroScreen( private fun PlayerIntroScreen(
amSubject: Boolean, amSubject: Boolean,

View File

@ -10,6 +10,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -66,11 +67,15 @@ import app.closer.core.navigation.AppRoute
import app.closer.data.remote.FirestoreThisOrThatDataSource import app.closer.data.remote.FirestoreThisOrThatDataSource
import app.closer.domain.model.ChoiceOption import app.closer.domain.model.ChoiceOption
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.model.SessionLength
import app.closer.domain.model.ThisOrThatAnswerConfig import app.closer.domain.model.ThisOrThatAnswerConfig
import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import android.content.Context
import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -131,12 +136,14 @@ data class ThisOrThatUiState(
val partnerName: String = "Your partner", val partnerName: String = "Your partner",
val matchedCount: Int = 0, val matchedCount: Int = 0,
val revealCards: List<RevealCard> = emptyList(), val revealCards: List<RevealCard> = emptyList(),
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null
) )
@HiltViewModel @HiltViewModel
class ThisOrThatViewModel @Inject constructor( class ThisOrThatViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager, private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreThisOrThatDataSource private val dataSource: FirestoreThisOrThatDataSource
@ -188,6 +195,10 @@ class ThisOrThatViewModel @Inject constructor(
} }
} }
fun setLength(len: SessionLength) {
_uiState.update { it.copy(selectedLength = len) }
}
fun chooseMood(mood: TotMood) { fun chooseMood(mood: TotMood) {
val uid = userId ?: return fail("You need to be signed in to play.") val uid = userId ?: return fail("You need to be signed in to play.")
_uiState.update { it.copy(phase = TotPhase.LOADING, error = null) } _uiState.update { it.copy(phase = TotPhase.LOADING, error = null) }
@ -202,10 +213,11 @@ class ThisOrThatViewModel @Inject constructor(
val moodQuestions = mood.categoryIds val moodQuestions = mood.categoryIds
?.let { categoryIds -> allQuestions.filter { it.category in categoryIds } } ?.let { categoryIds -> allQuestions.filter { it.category in categoryIds } }
?: allQuestions ?: allQuestions
val pool = moodQuestions.takeIf { it.size >= SESSION_SIZE } ?: allQuestions val count = _uiState.value.selectedLength.count
val pool = moodQuestions.takeIf { it.size >= count } ?: allQuestions
val picked = pool val picked = pool
.shuffled() .shuffled()
.take(SESSION_SIZE) .take(count)
if (picked.isEmpty()) return fail("No questions available.") if (picked.isEmpty()) return fail("No questions available.")
val startResult = runCatching { val startResult = runCatching {
@ -268,7 +280,10 @@ class ThisOrThatViewModel @Inject constructor(
if (s.pendingSelection != null || s.phase != TotPhase.PLAYING) return if (s.pendingSelection != null || s.phase != TotPhase.PLAYING) return
_uiState.update { it.copy(pendingSelection = optionId) } _uiState.update { it.copy(pendingSelection = optionId) }
viewModelScope.launch { viewModelScope.launch {
delay(ADVANCE_DELAY_MS) val reduceMotion = Settings.Global.getFloat(
context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f
) == 0f
delay(if (reduceMotion) 0L else ADVANCE_DELAY_MS)
val answers = _uiState.value.myAnswers + optionId val answers = _uiState.value.myAnswers + optionId
val next = _uiState.value.currentIndex + 1 val next = _uiState.value.currentIndex + 1
if (next >= _uiState.value.questions.size) { if (next >= _uiState.value.questions.size) {
@ -374,7 +389,6 @@ class ThisOrThatViewModel @Inject constructor(
} }
companion object { companion object {
const val SESSION_SIZE = 10
private const val ADVANCE_DELAY_MS = 420L private const val ADVANCE_DELAY_MS = 420L
private const val TAG = "ThisOrThatViewModel" private const val TAG = "ThisOrThatViewModel"
} }
@ -416,6 +430,8 @@ fun ThisOrThatScreen(
onAbandon = viewModel::abandon onAbandon = viewModel::abandon
) )
TotPhase.PICK_MOOD -> ThisOrThatMoodPicker( TotPhase.PICK_MOOD -> ThisOrThatMoodPicker(
selectedLength = state.selectedLength,
onLengthSelected = viewModel::setLength,
onMoodSelected = viewModel::chooseMood, onMoodSelected = viewModel::chooseMood,
onBack = viewModel::quit onBack = viewModel::quit
) )
@ -453,6 +469,8 @@ fun ThisOrThatScreen(
@Composable @Composable
private fun ThisOrThatMoodPicker( private fun ThisOrThatMoodPicker(
selectedLength: SessionLength,
onLengthSelected: (SessionLength) -> Unit,
onMoodSelected: (TotMood) -> Unit, onMoodSelected: (TotMood) -> Unit,
onBack: () -> Unit onBack: () -> Unit
) { ) {
@ -489,6 +507,10 @@ private fun ThisOrThatMoodPicker(
} }
} }
item {
TotLengthChips(selected = selectedLength, onSelect = onLengthSelected)
}
items(TotMood.values().toList()) { mood -> items(TotMood.values().toList()) { mood ->
Card( Card(
onClick = { onMoodSelected(mood) }, onClick = { onMoodSelected(mood) },
@ -1131,6 +1153,33 @@ private fun ErrorState(message: String, onBack: () -> Unit) {
} }
} }
// ── Length picker ─────────────────────────────────────────────────────────────
@Composable
private fun TotLengthChips(selected: SessionLength, onSelect: (SessionLength) -> Unit) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SessionLength.values().forEach { len ->
Surface(
onClick = { onSelect(len) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
color = if (len == selected) CloserPalette.PurpleDeep else Color.Transparent,
border = if (len != selected)
BorderStroke(1.dp, CloserPalette.PurpleDeep.copy(alpha = 0.4f))
else null
) {
Text(
text = len.label,
modifier = Modifier.padding(vertical = 10.dp),
style = MaterialTheme.typography.labelMedium,
color = if (len == selected) Color.White else CloserPalette.PurpleDeep,
textAlign = TextAlign.Center
)
}
}
}
}
// ── History Replay ──────────────────────────────────────────────────────────── // ── History Replay ────────────────────────────────────────────────────────────
sealed interface TotReplayPhase { sealed interface TotReplayPhase {

View File

@ -30,6 +30,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.foundation.layout.Row
import app.closer.domain.model.SessionLength
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -69,7 +71,8 @@ fun SpinWheelScreen(
SpinWheelContent( SpinWheelContent(
state = state, state = state,
onSpin = viewModel::spin, onSpin = viewModel::spin,
onStart = viewModel::startSession onStart = viewModel::startSession,
onLengthSelected = viewModel::setLength
) )
} }
@ -77,7 +80,8 @@ fun SpinWheelScreen(
private fun SpinWheelContent( private fun SpinWheelContent(
state: SpinWheelUiState, state: SpinWheelUiState,
onSpin: () -> Unit, onSpin: () -> Unit,
onStart: () -> Unit onStart: () -> Unit,
onLengthSelected: (SessionLength) -> Unit = {}
) { ) {
val infiniteTransition = rememberInfiniteTransition(label = "wheel") val infiniteTransition = rememberInfiniteTransition(label = "wheel")
val rotation by infiniteTransition.animateFloat( val rotation by infiniteTransition.animateFloat(
@ -149,6 +153,12 @@ private fun SpinWheelContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp) verticalArrangement = Arrangement.spacedBy(14.dp)
) { ) {
if (!state.isLoading && !state.isSpinning) {
WheelLengthChips(
selected = state.selectedLength,
onSelect = onLengthSelected
)
}
when { when {
state.error != null -> Text( state.error != null -> Text(
text = state.error, text = state.error,
@ -159,7 +169,7 @@ private fun SpinWheelContent(
) )
state.spunAndReady -> { state.spunAndReady -> {
Text( Text(
text = "${SpinWheelViewModel.SESSION_SIZE} questions selected", text = "${state.selectedLength.count} questions selected",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@ -192,7 +202,7 @@ private fun SpinWheelContent(
state.isLoading -> CircularProgressIndicator(color = CloserPalette.PurpleDeep) state.isLoading -> CircularProgressIndicator(color = CloserPalette.PurpleDeep)
else -> { else -> {
Text( Text(
text = "Tap to select ${SpinWheelViewModel.SESSION_SIZE} questions at random", text = "Tap to select ${state.selectedLength.count} questions at random",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@ -311,6 +321,37 @@ private fun WheelSpinner(
} }
} }
@Composable
private fun WheelLengthChips(
selected: SessionLength,
onSelect: (SessionLength) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SessionLength.values().forEach { len ->
Surface(
onClick = { onSelect(len) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
color = if (len == selected) CloserPalette.PurpleDeep else Color.Transparent,
border = if (len != selected)
BorderStroke(1.dp, CloserPalette.PurpleDeep.copy(alpha = 0.4f))
else null
) {
Text(
text = len.label,
modifier = Modifier.padding(vertical = 10.dp),
style = MaterialTheme.typography.labelMedium,
color = if (len == selected) Color.White else CloserPalette.PurpleDeep,
textAlign = TextAlign.Center
)
}
}
}
}
@Preview @Preview
@Composable @Composable
fun SpinWheelScreenPreview() { fun SpinWheelScreenPreview() {

View File

@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType import app.closer.domain.model.GameType
import app.closer.domain.model.SessionLength
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -21,6 +22,7 @@ data class SpinWheelUiState(
val categoryName: String = "", val categoryName: String = "",
val isSpinning: Boolean = false, val isSpinning: Boolean = false,
val spunAndReady: Boolean = false, val spunAndReady: Boolean = false,
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null
) )
@ -61,7 +63,7 @@ class SpinWheelViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isSpinning = true, error = null) } _uiState.update { it.copy(isSpinning = true, error = null) }
val questions = runCatching { val questions = runCatching {
repository.getQuestionsByCategory(categoryId).shuffled().take(SESSION_SIZE) repository.getQuestionsByCategory(categoryId).shuffled().take(_uiState.value.selectedLength.count)
} }
.onFailure { Log.w(TAG, "Could not load wheel questions", it) } .onFailure { Log.w(TAG, "Could not load wheel questions", it) }
.getOrElse { emptyList() } .getOrElse { emptyList() }
@ -139,12 +141,16 @@ class SpinWheelViewModel @Inject constructor(
} }
} }
fun setLength(len: SessionLength) {
// Resets spunAndReady so the user re-spins with the new count.
_uiState.update { it.copy(selectedLength = len, spunAndReady = false) }
}
fun onNavigated() { fun onNavigated() {
_uiState.update { it.copy(navigateTo = null) } _uiState.update { it.copy(navigateTo = null) }
} }
companion object { companion object {
const val SESSION_SIZE = 10
private const val TAG = "SpinWheelViewModel" private const val TAG = "SpinWheelViewModel"
} }
} }