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 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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue