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 androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -55,11 +56,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
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.Question
import app.closer.domain.model.SessionLength
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.usecase.GameSessionManager
import dagger.hilt.android.qualifiers.ApplicationContext
import app.closer.ui.components.StatusGlyph
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush
@ -80,7 +85,7 @@ data class DesireMatch(
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(
val phase: DesireSyncPhase = DesireSyncPhase.LOADING,
@ -90,6 +95,7 @@ data class DesireSyncUiState(
val myAnswers: List<String> = emptyList(),
val partnerName: String = "Your partner",
val matches: List<DesireMatch> = emptyList(),
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null,
val navigateTo: String? = null
)
@ -106,6 +112,7 @@ private fun isBinaryQuestion(q: Question): Boolean {
@HiltViewModel
class DesireSyncViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource
@ -152,14 +159,24 @@ class DesireSyncViewModel @Inject constructor(
// A different game is already in progress — respect the one-game lock.
_uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) }
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. */
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.")
val startResult = runCatching {
@ -214,7 +231,10 @@ class DesireSyncViewModel @Inject constructor(
if (s.pendingSelection != null || s.phase != DesireSyncPhase.ANSWER) return
_uiState.update { it.copy(pendingSelection = optionId) }
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 next = _uiState.value.currentIndex + 1
if (next >= _uiState.value.questions.size) {
@ -322,7 +342,6 @@ class DesireSyncViewModel @Inject constructor(
}
companion object {
private const val SESSION_SIZE = 10
private const val ADVANCE_DELAY_MS = 380L
private const val TAG = "DesireSyncViewModel"
}
@ -358,6 +377,12 @@ fun DesireSyncScreen(
message = state.error ?: "Something went wrong.",
onBack = viewModel::quit
)
DesireSyncPhase.SETUP -> DSSetupScreen(
selectedLength = state.selectedLength,
onLengthSelected = viewModel::setLength,
onStart = viewModel::startGame,
onBack = viewModel::quit
)
DesireSyncPhase.INTRO -> DSIntroScreen(
total = state.questions.size,
onReady = viewModel::startAnswering
@ -391,6 +416,72 @@ fun DesireSyncScreen(
// ── 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
private fun DSIntroScreen(total: Int, onReady: () -> Unit) {
Column(

View File

@ -3,6 +3,7 @@ package app.closer.ui.howwell
import app.closer.domain.model.GameType
import app.closer.ui.theme.closerCardColor
import android.util.Log
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@ -57,12 +58,15 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import android.content.Context
import app.closer.domain.model.AnswerConfig
import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.ChoiceOption
import app.closer.domain.model.Question
import app.closer.domain.model.ScaleAnswerConfigImpl
import app.closer.domain.model.SessionLength
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import dagger.hilt.android.qualifiers.ApplicationContext
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionSessionRepository
import app.closer.data.remote.FirestoreHowWellDataSource
@ -122,7 +126,7 @@ data class HowWellResult(
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(
val phase: HowWellPhase = HowWellPhase.LOADING,
@ -135,6 +139,7 @@ data class HowWellUiState(
val partnerName: String = "Your partner",
val results: List<HowWellResult> = emptyList(),
val score: Int = 0,
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null,
val navigateTo: String? = null
)
@ -143,6 +148,7 @@ data class HowWellUiState(
@HiltViewModel
class HowWellViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreHowWellDataSource
@ -188,18 +194,28 @@ class HowWellViewModel @Inject constructor(
// A different game is already in progress — respect the one-game lock.
_uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) }
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. */
private suspend fun createSession(uid: String) {
val questions = runCatching { repository.getQuestionsForPrediction() }
.onFailure { Log.w(TAG, "Failed to load prediction questions", it) }
.getOrElse { emptyList() }
.shuffled()
.take(SESSION_SIZE)
.take(_uiState.value.selectedLength.count)
if (questions.isEmpty()) return fail("No questions available.")
val startResult = runCatching {
@ -371,7 +387,6 @@ class HowWellViewModel @Inject constructor(
}
companion object {
const val SESSION_SIZE = 10
private const val TAG = "HowWellViewModel"
}
}
@ -406,6 +421,12 @@ fun HowWellScreen(
message = state.error ?: "Something went wrong.",
onBack = viewModel::quit
)
HowWellPhase.SETUP -> HWSetupScreen(
selectedLength = state.selectedLength,
onLengthSelected = viewModel::setLength,
onStart = viewModel::startGame,
onBack = viewModel::quit
)
HowWellPhase.INTRO -> PlayerIntroScreen(
amSubject = state.amSubject,
partnerName = state.partnerName,
@ -449,6 +470,72 @@ fun HowWellScreen(
// ── 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
private fun PlayerIntroScreen(
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.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.Canvas
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.domain.model.ChoiceOption
import app.closer.domain.model.Question
import app.closer.domain.model.SessionLength
import app.closer.domain.model.ThisOrThatAnswerConfig
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionSessionRepository
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.closerBackgroundBrush
import dagger.hilt.android.lifecycle.HiltViewModel
@ -131,12 +136,14 @@ data class ThisOrThatUiState(
val partnerName: String = "Your partner",
val matchedCount: Int = 0,
val revealCards: List<RevealCard> = emptyList(),
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null,
val navigateTo: String? = null
)
@HiltViewModel
class ThisOrThatViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager,
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) {
val uid = userId ?: return fail("You need to be signed in to play.")
_uiState.update { it.copy(phase = TotPhase.LOADING, error = null) }
@ -202,10 +213,11 @@ class ThisOrThatViewModel @Inject constructor(
val moodQuestions = mood.categoryIds
?.let { categoryIds -> allQuestions.filter { it.category in categoryIds } }
?: 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
.shuffled()
.take(SESSION_SIZE)
.take(count)
if (picked.isEmpty()) return fail("No questions available.")
val startResult = runCatching {
@ -268,7 +280,10 @@ class ThisOrThatViewModel @Inject constructor(
if (s.pendingSelection != null || s.phase != TotPhase.PLAYING) return
_uiState.update { it.copy(pendingSelection = optionId) }
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 next = _uiState.value.currentIndex + 1
if (next >= _uiState.value.questions.size) {
@ -374,7 +389,6 @@ class ThisOrThatViewModel @Inject constructor(
}
companion object {
const val SESSION_SIZE = 10
private const val ADVANCE_DELAY_MS = 420L
private const val TAG = "ThisOrThatViewModel"
}
@ -416,6 +430,8 @@ fun ThisOrThatScreen(
onAbandon = viewModel::abandon
)
TotPhase.PICK_MOOD -> ThisOrThatMoodPicker(
selectedLength = state.selectedLength,
onLengthSelected = viewModel::setLength,
onMoodSelected = viewModel::chooseMood,
onBack = viewModel::quit
)
@ -453,6 +469,8 @@ fun ThisOrThatScreen(
@Composable
private fun ThisOrThatMoodPicker(
selectedLength: SessionLength,
onLengthSelected: (SessionLength) -> Unit,
onMoodSelected: (TotMood) -> Unit,
onBack: () -> Unit
) {
@ -489,6 +507,10 @@ private fun ThisOrThatMoodPicker(
}
}
item {
TotLengthChips(selected = selectedLength, onSelect = onLengthSelected)
}
items(TotMood.values().toList()) { mood ->
Card(
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 ────────────────────────────────────────────────────────────
sealed interface TotReplayPhase {

View File

@ -30,6 +30,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -69,7 +71,8 @@ fun SpinWheelScreen(
SpinWheelContent(
state = state,
onSpin = viewModel::spin,
onStart = viewModel::startSession
onStart = viewModel::startSession,
onLengthSelected = viewModel::setLength
)
}
@ -77,7 +80,8 @@ fun SpinWheelScreen(
private fun SpinWheelContent(
state: SpinWheelUiState,
onSpin: () -> Unit,
onStart: () -> Unit
onStart: () -> Unit,
onLengthSelected: (SessionLength) -> Unit = {}
) {
val infiniteTransition = rememberInfiniteTransition(label = "wheel")
val rotation by infiniteTransition.animateFloat(
@ -149,6 +153,12 @@ private fun SpinWheelContent(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
if (!state.isLoading && !state.isSpinning) {
WheelLengthChips(
selected = state.selectedLength,
onSelect = onLengthSelected
)
}
when {
state.error != null -> Text(
text = state.error,
@ -159,7 +169,7 @@ private fun SpinWheelContent(
)
state.spunAndReady -> {
Text(
text = "${SpinWheelViewModel.SESSION_SIZE} questions selected",
text = "${state.selectedLength.count} questions selected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@ -192,7 +202,7 @@ private fun SpinWheelContent(
state.isLoading -> CircularProgressIndicator(color = CloserPalette.PurpleDeep)
else -> {
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,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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
@Composable
fun SpinWheelScreenPreview() {

View File

@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType
import app.closer.domain.model.SessionLength
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameSessionManager
import dagger.hilt.android.lifecycle.HiltViewModel
@ -21,6 +22,7 @@ data class SpinWheelUiState(
val categoryName: String = "",
val isSpinning: Boolean = false,
val spunAndReady: Boolean = false,
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null,
val navigateTo: String? = null
)
@ -61,7 +63,7 @@ class SpinWheelViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(isSpinning = true, error = null) }
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) }
.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() {
_uiState.update { it.copy(navigateTo = null) }
}
companion object {
const val SESSION_SIZE = 10
private const val TAG = "SpinWheelViewModel"
}
}