feat: challenge state machine, game screen updates, state machine tests
This commit is contained in:
parent
74964a86d8
commit
edfef1b6ca
|
|
@ -109,7 +109,7 @@ object ChallengeStateMachine {
|
|||
else -> ChallengeStatus.NOT_STARTED
|
||||
}
|
||||
|
||||
val copy = copyFor(status, challenge.title)
|
||||
val copy = copyFor(status, challenge.title, totalDays)
|
||||
val cta = ctaFor(status, myDoneStatus, currentDay, totalDays)
|
||||
val canAdvance = when (status) {
|
||||
ChallengeStatus.CHALLENGE_COMPLETE -> false
|
||||
|
|
@ -151,8 +151,8 @@ object ChallengeStateMachine {
|
|||
emoji = emoji,
|
||||
totalDays = totalDays,
|
||||
currentDay = 1,
|
||||
copy = "Start a $totalDays-day rhythm together.",
|
||||
cta = "Start challenge",
|
||||
copy = "Your $totalDays-day challenge is ready — do today's step and mark it done.",
|
||||
cta = "I did it today",
|
||||
canAdvance = true
|
||||
)
|
||||
|
||||
|
|
@ -183,9 +183,9 @@ object ChallengeStateMachine {
|
|||
// Copy
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
internal fun copyFor(status: ChallengeStatus, title: String): String = when (status) {
|
||||
ChallengeStatus.NOT_STARTED -> "Start a 7-day rhythm together."
|
||||
ChallengeStatus.STARTED_TODAY -> "You started a 7-day rhythm."
|
||||
internal fun copyFor(status: ChallengeStatus, title: String, durationDays: Int): String = when (status) {
|
||||
ChallengeStatus.NOT_STARTED -> "Do today's step together — $durationDays days total."
|
||||
ChallengeStatus.STARTED_TODAY -> "Your partner completed today — now it's your turn."
|
||||
ChallengeStatus.WAITING_FOR_PARTNER -> "You did your part today. Waiting for your partner."
|
||||
ChallengeStatus.BOTH_COMPLETED_TODAY -> "Both of you showed up today."
|
||||
ChallengeStatus.DAY_MISSED -> "No shame. Pick it back up tonight."
|
||||
|
|
@ -198,11 +198,10 @@ object ChallengeStateMachine {
|
|||
currentDay: Int,
|
||||
totalDays: Int
|
||||
): String? = when (status) {
|
||||
ChallengeStatus.NOT_STARTED -> "Start challenge"
|
||||
ChallengeStatus.NOT_STARTED -> "I did it today"
|
||||
ChallengeStatus.STARTED_TODAY ->
|
||||
if (myDoneToday) "Waiting for partner" else "I did it today"
|
||||
ChallengeStatus.WAITING_FOR_PARTNER ->
|
||||
if (myDoneToday) "Send a gentle reminder" else "I did it today"
|
||||
if (myDoneToday) null else "I did it today"
|
||||
ChallengeStatus.WAITING_FOR_PARTNER -> null // already done; no action available
|
||||
ChallengeStatus.BOTH_COMPLETED_TODAY ->
|
||||
if (currentDay < totalDays) "See tomorrow's step" else "Challenge complete"
|
||||
ChallengeStatus.DAY_MISSED -> "Pick it back up"
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.data.challenges.ChallengesCatalog
|
||||
import app.closer.data.remote.FirestoreChallengeDataSource
|
||||
|
|
@ -70,6 +71,8 @@ import kotlinx.coroutines.Job
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -85,6 +88,7 @@ data class ChallengesUiState(
|
|||
val coupleId: String? = null,
|
||||
val userId: String? = null,
|
||||
val partnerId: String? = null,
|
||||
val hasPremium: Boolean = false,
|
||||
val error: String? = null,
|
||||
val navigateTo: String? = null
|
||||
)
|
||||
|
|
@ -93,7 +97,8 @@ data class ChallengesUiState(
|
|||
class ConnectionChallengesViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val challengeDataSource: FirestoreChallengeDataSource
|
||||
private val challengeDataSource: FirestoreChallengeDataSource,
|
||||
private val entitlementChecker: EntitlementChecker
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ChallengesUiState())
|
||||
|
|
@ -102,6 +107,9 @@ class ConnectionChallengesViewModel @Inject constructor(
|
|||
private var progressJob: Job? = null
|
||||
|
||||
init {
|
||||
entitlementChecker.isPremium()
|
||||
.onEach { premium -> _uiState.update { it.copy(hasPremium = premium) } }
|
||||
.launchIn(viewModelScope)
|
||||
load()
|
||||
}
|
||||
|
||||
|
|
@ -177,8 +185,14 @@ class ConnectionChallengesViewModel @Inject constructor(
|
|||
|
||||
fun startChallenge(challenge: ConnectionChallenge) {
|
||||
val state = _uiState.value
|
||||
val coupleId = state.coupleId ?: return
|
||||
val userId = state.userId ?: return
|
||||
val coupleId = state.coupleId ?: run {
|
||||
_uiState.update { it.copy(error = "Something went wrong. Go back and try again.") }
|
||||
return
|
||||
}
|
||||
val userId = state.userId ?: run {
|
||||
_uiState.update { it.copy(error = "Something went wrong. Go back and try again.") }
|
||||
return
|
||||
}
|
||||
val partnerId = state.partnerId ?: userId
|
||||
viewModelScope.launch {
|
||||
runCatching { challengeDataSource.startChallenge(coupleId, challenge.id) }
|
||||
|
|
@ -234,6 +248,7 @@ fun ConnectionChallengesScreen(
|
|||
when (state.phase) {
|
||||
ChallengesPhase.LOADING -> ChallengesLoadingScreen()
|
||||
ChallengesPhase.PICK -> ChallengesPickScreen(
|
||||
hasPremium = state.hasPremium,
|
||||
onBack = { onNavigate(AppRoute.PLAY) },
|
||||
onPick = { viewModel.startChallenge(it) },
|
||||
onPaywall = { onNavigate(AppRoute.PAYWALL) }
|
||||
|
|
@ -262,6 +277,7 @@ private fun ChallengesLoadingScreen() {
|
|||
|
||||
@Composable
|
||||
private fun ChallengesPickScreen(
|
||||
hasPremium: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onPick: (ConnectionChallenge) -> Unit,
|
||||
onPaywall: () -> Unit
|
||||
|
|
@ -303,7 +319,7 @@ private fun ChallengesPickScreen(
|
|||
}
|
||||
|
||||
items(ChallengesCatalog.all) { challenge ->
|
||||
ChallengePickCard(challenge = challenge, onPick = onPick, onPaywall = onPaywall)
|
||||
ChallengePickCard(challenge = challenge, hasPremium = hasPremium, onPick = onPick, onPaywall = onPaywall)
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
|
|
@ -313,11 +329,12 @@ private fun ChallengesPickScreen(
|
|||
@Composable
|
||||
private fun ChallengePickCard(
|
||||
challenge: ConnectionChallenge,
|
||||
hasPremium: Boolean,
|
||||
onPick: (ConnectionChallenge) -> Unit,
|
||||
onPaywall: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = { if (challenge.isPremium) onPaywall() else onPick(challenge) },
|
||||
onClick = { if (challenge.isPremium && !hasPremium) onPaywall() else onPick(challenge) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ data class DesireSyncUiState(
|
|||
val matches: List<DesireMatch> = emptyList(),
|
||||
val selectedLength: SessionLength = SessionLength.STANDARD,
|
||||
val error: String? = null,
|
||||
val submitFailed: Boolean = false,
|
||||
val navigateTo: String? = null
|
||||
)
|
||||
|
||||
|
|
@ -268,11 +269,18 @@ class DesireSyncViewModel @Inject constructor(
|
|||
.onFailure { e ->
|
||||
submitted = false
|
||||
Log.w(TAG, "Could not submit answers", e)
|
||||
_uiState.update { it.copy(phase = DesireSyncPhase.ERROR, error = "Couldn't save your answers. Check your connection and go back to try again.") }
|
||||
_uiState.update { it.copy(phase = DesireSyncPhase.ERROR, error = "Couldn't save your answers. Check your connection and try again.", submitFailed = true) }
|
||||
}
|
||||
// The observer flips WAITING → REVEAL once the partner's answers land.
|
||||
}
|
||||
|
||||
fun retrySubmit() {
|
||||
val answers = _uiState.value.myAnswers
|
||||
if (answers.isEmpty()) return
|
||||
_uiState.update { it.copy(phase = DesireSyncPhase.WAITING, error = null, submitFailed = false) }
|
||||
viewModelScope.launch { submitAnswers(answers) }
|
||||
}
|
||||
|
||||
/** Single source of truth for WAITING/REVEAL: driven by what's in Firestore. */
|
||||
private fun observeReveal() {
|
||||
val cId = coupleId ?: return
|
||||
|
|
@ -391,7 +399,8 @@ fun DesireSyncScreen(
|
|||
)
|
||||
DesireSyncPhase.ERROR -> DSErrorScreen(
|
||||
message = state.error ?: "Something didn't load. Go back and try again.",
|
||||
onBack = viewModel::quit
|
||||
onBack = viewModel::quit,
|
||||
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
|
||||
)
|
||||
DesireSyncPhase.SETUP -> DSSetupScreen(
|
||||
selectedLength = state.selectedLength,
|
||||
|
|
@ -587,7 +596,7 @@ private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit, onAbandon:
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun DSErrorScreen(message: String, onBack: () -> Unit) {
|
||||
private fun DSErrorScreen(message: String, onBack: () -> Unit, onRetry: (() -> Unit)? = null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -604,6 +613,10 @@ private fun DSErrorScreen(message: String, onBack: () -> Unit) {
|
|||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
if (onRetry != null) {
|
||||
Button(onClick = onRetry, shape = RoundedCornerShape(18.dp)) { Text("Try again") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
OutlinedButton(onClick = onBack, shape = RoundedCornerShape(18.dp)) {
|
||||
Text("Back to Play")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ data class HowWellUiState(
|
|||
val score: Int = 0,
|
||||
val selectedLength: SessionLength = SessionLength.STANDARD,
|
||||
val error: String? = null,
|
||||
val submitFailed: Boolean = false,
|
||||
val navigateTo: String? = null
|
||||
)
|
||||
|
||||
|
|
@ -298,11 +299,17 @@ class HowWellViewModel @Inject constructor(
|
|||
.onFailure { e ->
|
||||
submitted = false
|
||||
Log.w(TAG, "Could not submit answers", e)
|
||||
_uiState.update { it.copy(phase = HowWellPhase.ERROR, error = "Couldn't save your answers. Check your connection and go back to try again.") }
|
||||
_uiState.update { it.copy(phase = HowWellPhase.ERROR, error = "Couldn't save your answers. Check your connection and try again.", submitFailed = true) }
|
||||
}
|
||||
// The observer flips WAITING → COMPLETE once the partner's answers land.
|
||||
}
|
||||
|
||||
fun retrySubmit() {
|
||||
if (myAnswers.isEmpty()) return
|
||||
_uiState.update { it.copy(phase = HowWellPhase.WAITING, error = null, submitFailed = false) }
|
||||
viewModelScope.launch { submitAnswers() }
|
||||
}
|
||||
|
||||
/** Single source of truth for WAITING/COMPLETE: driven by what's in Firestore. */
|
||||
private fun observeReveal() {
|
||||
val cId = coupleId ?: return
|
||||
|
|
@ -431,7 +438,8 @@ fun HowWellScreen(
|
|||
)
|
||||
HowWellPhase.ERROR -> HowWellErrorScreen(
|
||||
message = state.error ?: "Something didn't load. Go back and try again.",
|
||||
onBack = viewModel::quit
|
||||
onBack = viewModel::quit,
|
||||
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
|
||||
)
|
||||
HowWellPhase.SETUP -> HWSetupScreen(
|
||||
selectedLength = state.selectedLength,
|
||||
|
|
@ -652,7 +660,7 @@ private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun HowWellErrorScreen(message: String, onBack: () -> Unit) {
|
||||
private fun HowWellErrorScreen(message: String, onBack: () -> Unit, onRetry: (() -> Unit)? = null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -669,6 +677,10 @@ private fun HowWellErrorScreen(message: String, onBack: () -> Unit) {
|
|||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
if (onRetry != null) {
|
||||
Button(onClick = onRetry, shape = RoundedCornerShape(18.dp)) { Text("Try again") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
OutlinedButton(onClick = onBack, shape = RoundedCornerShape(18.dp)) {
|
||||
Text("Back to Play")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ data class ThisOrThatUiState(
|
|||
val revealCards: List<RevealCard> = emptyList(),
|
||||
val selectedLength: SessionLength = SessionLength.STANDARD,
|
||||
val error: String? = null,
|
||||
val submitFailed: Boolean = false,
|
||||
val navigateTo: String? = null
|
||||
)
|
||||
|
||||
|
|
@ -311,7 +312,7 @@ class ThisOrThatViewModel @Inject constructor(
|
|||
.onFailure { e ->
|
||||
submitted = false
|
||||
Log.w(TAG, "Could not submit answers", e)
|
||||
_uiState.update { it.copy(phase = TotPhase.ERROR, error = "Couldn't save your answers. Check your connection and go back to try again.") }
|
||||
_uiState.update { it.copy(phase = TotPhase.ERROR, error = "Couldn't save your answers. Check your connection and try again.", submitFailed = true) }
|
||||
}
|
||||
// The observer flips WAITING → REVEAL once the partner's answers land
|
||||
// (or right away, if they finished first).
|
||||
|
|
@ -383,6 +384,13 @@ class ThisOrThatViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun retrySubmit() {
|
||||
val answers = _uiState.value.myAnswers
|
||||
if (answers.isEmpty()) return
|
||||
_uiState.update { it.copy(phase = TotPhase.WAITING, error = null, submitFailed = false) }
|
||||
viewModelScope.launch { submitAnswers(answers) }
|
||||
}
|
||||
|
||||
fun restart() {
|
||||
observeJob?.cancel()
|
||||
sessionId = null
|
||||
|
|
@ -432,7 +440,8 @@ fun ThisOrThatScreen(
|
|||
)
|
||||
TotPhase.ERROR -> ErrorState(
|
||||
message = state.error ?: "Something didn't load. Go back and try again.",
|
||||
onBack = viewModel::quit
|
||||
onBack = viewModel::quit,
|
||||
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
|
||||
)
|
||||
TotPhase.WAITING -> WaitingForRevealScreen(
|
||||
partnerName = state.partnerName,
|
||||
|
|
@ -1141,7 +1150,7 @@ private fun PickChip(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorState(message: String, onBack: () -> Unit) {
|
||||
private fun ErrorState(message: String, onBack: () -> Unit, onRetry: (() -> Unit)? = null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -1157,6 +1166,10 @@ private fun ErrorState(message: String, onBack: () -> Unit) {
|
|||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
if (onRetry != null) {
|
||||
Button(onClick = onRetry) { Text("Try again") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
OutlinedButton(onClick = onBack) {
|
||||
Text("Back")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ class ChallengeStateMachineTest {
|
|||
val state = ChallengeStateMachine.compute(input)
|
||||
|
||||
assertEquals(ChallengeStatus.NOT_STARTED, state.state)
|
||||
assertEquals("Start a 7-day rhythm together.", state.copy)
|
||||
assertEquals("Start challenge", state.cta)
|
||||
assertEquals("Your 7-day challenge is ready — do today's step and mark it done.", state.copy)
|
||||
assertEquals("I did it today", state.cta)
|
||||
assertTrue(state.canAdvance)
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ class ChallengeStateMachineTest {
|
|||
val state = ChallengeStateMachine.compute(input)
|
||||
|
||||
assertEquals(ChallengeStatus.STARTED_TODAY, state.state)
|
||||
assertEquals("You started a 7-day rhythm.", state.copy)
|
||||
assertEquals("Your partner completed today — now it's your turn.", state.copy)
|
||||
assertEquals("I did it today", state.cta)
|
||||
assertTrue(state.canAdvance)
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ class ChallengeStateMachineTest {
|
|||
|
||||
assertEquals(ChallengeStatus.WAITING_FOR_PARTNER, state.state)
|
||||
assertEquals("You did your part today. Waiting for your partner.", state.copy)
|
||||
assertEquals("Send a gentle reminder", state.cta)
|
||||
assertEquals(null, state.cta)
|
||||
assertFalse(state.canAdvance)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue