feat: add SessionLength model, update game screens with session length awareness
This commit is contained in:
parent
0e0a33a6dd
commit
927e930b79
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue