feat: challenge state machine, game screen updates, state machine tests

This commit is contained in:
null 2026-06-22 22:02:39 -05:00
parent c56dd53edd
commit c97371a12e
6 changed files with 82 additions and 28 deletions

View File

@ -109,7 +109,7 @@ object ChallengeStateMachine {
else -> ChallengeStatus.NOT_STARTED 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 cta = ctaFor(status, myDoneStatus, currentDay, totalDays)
val canAdvance = when (status) { val canAdvance = when (status) {
ChallengeStatus.CHALLENGE_COMPLETE -> false ChallengeStatus.CHALLENGE_COMPLETE -> false
@ -151,8 +151,8 @@ object ChallengeStateMachine {
emoji = emoji, emoji = emoji,
totalDays = totalDays, totalDays = totalDays,
currentDay = 1, currentDay = 1,
copy = "Start a $totalDays-day rhythm together.", copy = "Your $totalDays-day challenge is ready — do today's step and mark it done.",
cta = "Start challenge", cta = "I did it today",
canAdvance = true canAdvance = true
) )
@ -183,9 +183,9 @@ object ChallengeStateMachine {
// Copy // Copy
// ----------------------------------------------------------------- // -----------------------------------------------------------------
internal fun copyFor(status: ChallengeStatus, title: String): String = when (status) { internal fun copyFor(status: ChallengeStatus, title: String, durationDays: Int): String = when (status) {
ChallengeStatus.NOT_STARTED -> "Start a 7-day rhythm together." ChallengeStatus.NOT_STARTED -> "Do today's step together — $durationDays days total."
ChallengeStatus.STARTED_TODAY -> "You started a 7-day rhythm." 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.WAITING_FOR_PARTNER -> "You did your part today. Waiting for your partner."
ChallengeStatus.BOTH_COMPLETED_TODAY -> "Both of you showed up today." ChallengeStatus.BOTH_COMPLETED_TODAY -> "Both of you showed up today."
ChallengeStatus.DAY_MISSED -> "No shame. Pick it back up tonight." ChallengeStatus.DAY_MISSED -> "No shame. Pick it back up tonight."
@ -198,11 +198,10 @@ object ChallengeStateMachine {
currentDay: Int, currentDay: Int,
totalDays: Int totalDays: Int
): String? = when (status) { ): String? = when (status) {
ChallengeStatus.NOT_STARTED -> "Start challenge" ChallengeStatus.NOT_STARTED -> "I did it today"
ChallengeStatus.STARTED_TODAY -> ChallengeStatus.STARTED_TODAY ->
if (myDoneToday) "Waiting for partner" else "I did it today" if (myDoneToday) null else "I did it today"
ChallengeStatus.WAITING_FOR_PARTNER -> ChallengeStatus.WAITING_FOR_PARTNER -> null // already done; no action available
if (myDoneToday) "Send a gentle reminder" else "I did it today"
ChallengeStatus.BOTH_COMPLETED_TODAY -> ChallengeStatus.BOTH_COMPLETED_TODAY ->
if (currentDay < totalDays) "See tomorrow's step" else "Challenge complete" if (currentDay < totalDays) "See tomorrow's step" else "Challenge complete"
ChallengeStatus.DAY_MISSED -> "Pick it back up" ChallengeStatus.DAY_MISSED -> "Pick it back up"

View File

@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.data.challenges.ChallengesCatalog import app.closer.data.challenges.ChallengesCatalog
import app.closer.data.remote.FirestoreChallengeDataSource import app.closer.data.remote.FirestoreChallengeDataSource
@ -70,6 +71,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -85,6 +88,7 @@ data class ChallengesUiState(
val coupleId: String? = null, val coupleId: String? = null,
val userId: String? = null, val userId: String? = null,
val partnerId: String? = null, val partnerId: String? = null,
val hasPremium: Boolean = false,
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null
) )
@ -93,7 +97,8 @@ data class ChallengesUiState(
class ConnectionChallengesViewModel @Inject constructor( class ConnectionChallengesViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val challengeDataSource: FirestoreChallengeDataSource private val challengeDataSource: FirestoreChallengeDataSource,
private val entitlementChecker: EntitlementChecker
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ChallengesUiState()) private val _uiState = MutableStateFlow(ChallengesUiState())
@ -102,6 +107,9 @@ class ConnectionChallengesViewModel @Inject constructor(
private var progressJob: Job? = null private var progressJob: Job? = null
init { init {
entitlementChecker.isPremium()
.onEach { premium -> _uiState.update { it.copy(hasPremium = premium) } }
.launchIn(viewModelScope)
load() load()
} }
@ -177,8 +185,14 @@ class ConnectionChallengesViewModel @Inject constructor(
fun startChallenge(challenge: ConnectionChallenge) { fun startChallenge(challenge: ConnectionChallenge) {
val state = _uiState.value val state = _uiState.value
val coupleId = state.coupleId ?: return val coupleId = state.coupleId ?: run {
val userId = state.userId ?: return _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 val partnerId = state.partnerId ?: userId
viewModelScope.launch { viewModelScope.launch {
runCatching { challengeDataSource.startChallenge(coupleId, challenge.id) } runCatching { challengeDataSource.startChallenge(coupleId, challenge.id) }
@ -234,6 +248,7 @@ fun ConnectionChallengesScreen(
when (state.phase) { when (state.phase) {
ChallengesPhase.LOADING -> ChallengesLoadingScreen() ChallengesPhase.LOADING -> ChallengesLoadingScreen()
ChallengesPhase.PICK -> ChallengesPickScreen( ChallengesPhase.PICK -> ChallengesPickScreen(
hasPremium = state.hasPremium,
onBack = { onNavigate(AppRoute.PLAY) }, onBack = { onNavigate(AppRoute.PLAY) },
onPick = { viewModel.startChallenge(it) }, onPick = { viewModel.startChallenge(it) },
onPaywall = { onNavigate(AppRoute.PAYWALL) } onPaywall = { onNavigate(AppRoute.PAYWALL) }
@ -262,6 +277,7 @@ private fun ChallengesLoadingScreen() {
@Composable @Composable
private fun ChallengesPickScreen( private fun ChallengesPickScreen(
hasPremium: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onPick: (ConnectionChallenge) -> Unit, onPick: (ConnectionChallenge) -> Unit,
onPaywall: () -> Unit onPaywall: () -> Unit
@ -303,7 +319,7 @@ private fun ChallengesPickScreen(
} }
items(ChallengesCatalog.all) { challenge -> 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)) } item { Spacer(Modifier.height(8.dp)) }
@ -313,11 +329,12 @@ private fun ChallengesPickScreen(
@Composable @Composable
private fun ChallengePickCard( private fun ChallengePickCard(
challenge: ConnectionChallenge, challenge: ConnectionChallenge,
hasPremium: Boolean,
onPick: (ConnectionChallenge) -> Unit, onPick: (ConnectionChallenge) -> Unit,
onPaywall: () -> Unit onPaywall: () -> Unit
) { ) {
Card( Card(
onClick = { if (challenge.isPremium) onPaywall() else onPick(challenge) }, onClick = { if (challenge.isPremium && !hasPremium) onPaywall() else onPick(challenge) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),

View File

@ -99,6 +99,7 @@ data class DesireSyncUiState(
val matches: List<DesireMatch> = emptyList(), val matches: List<DesireMatch> = emptyList(),
val selectedLength: SessionLength = SessionLength.STANDARD, val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null, val error: String? = null,
val submitFailed: Boolean = false,
val navigateTo: String? = null val navigateTo: String? = null
) )
@ -268,11 +269,18 @@ class DesireSyncViewModel @Inject constructor(
.onFailure { e -> .onFailure { e ->
submitted = false submitted = false
Log.w(TAG, "Could not submit answers", e) 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. // 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. */ /** Single source of truth for WAITING/REVEAL: driven by what's in Firestore. */
private fun observeReveal() { private fun observeReveal() {
val cId = coupleId ?: return val cId = coupleId ?: return
@ -391,7 +399,8 @@ fun DesireSyncScreen(
) )
DesireSyncPhase.ERROR -> DSErrorScreen( DesireSyncPhase.ERROR -> DSErrorScreen(
message = state.error ?: "Something didn't load. Go back and try again.", 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( DesireSyncPhase.SETUP -> DSSetupScreen(
selectedLength = state.selectedLength, selectedLength = state.selectedLength,
@ -587,7 +596,7 @@ private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit, onAbandon:
} }
@Composable @Composable
private fun DSErrorScreen(message: String, onBack: () -> Unit) { private fun DSErrorScreen(message: String, onBack: () -> Unit, onRetry: (() -> Unit)? = null) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -604,6 +613,10 @@ private fun DSErrorScreen(message: String, onBack: () -> Unit) {
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(20.dp)) 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)) { OutlinedButton(onClick = onBack, shape = RoundedCornerShape(18.dp)) {
Text("Back to Play") Text("Back to Play")
} }

View File

@ -142,6 +142,7 @@ data class HowWellUiState(
val score: Int = 0, val score: Int = 0,
val selectedLength: SessionLength = SessionLength.STANDARD, val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null, val error: String? = null,
val submitFailed: Boolean = false,
val navigateTo: String? = null val navigateTo: String? = null
) )
@ -298,11 +299,17 @@ class HowWellViewModel @Inject constructor(
.onFailure { e -> .onFailure { e ->
submitted = false submitted = false
Log.w(TAG, "Could not submit answers", e) 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. // 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. */ /** Single source of truth for WAITING/COMPLETE: driven by what's in Firestore. */
private fun observeReveal() { private fun observeReveal() {
val cId = coupleId ?: return val cId = coupleId ?: return
@ -431,7 +438,8 @@ fun HowWellScreen(
) )
HowWellPhase.ERROR -> HowWellErrorScreen( HowWellPhase.ERROR -> HowWellErrorScreen(
message = state.error ?: "Something didn't load. Go back and try again.", 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( HowWellPhase.SETUP -> HWSetupScreen(
selectedLength = state.selectedLength, selectedLength = state.selectedLength,
@ -652,7 +660,7 @@ private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack
} }
@Composable @Composable
private fun HowWellErrorScreen(message: String, onBack: () -> Unit) { private fun HowWellErrorScreen(message: String, onBack: () -> Unit, onRetry: (() -> Unit)? = null) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -669,6 +677,10 @@ private fun HowWellErrorScreen(message: String, onBack: () -> Unit) {
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(20.dp)) 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)) { OutlinedButton(onClick = onBack, shape = RoundedCornerShape(18.dp)) {
Text("Back to Play") Text("Back to Play")
} }

View File

@ -139,6 +139,7 @@ data class ThisOrThatUiState(
val revealCards: List<RevealCard> = emptyList(), val revealCards: List<RevealCard> = emptyList(),
val selectedLength: SessionLength = SessionLength.STANDARD, val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null, val error: String? = null,
val submitFailed: Boolean = false,
val navigateTo: String? = null val navigateTo: String? = null
) )
@ -311,7 +312,7 @@ class ThisOrThatViewModel @Inject constructor(
.onFailure { e -> .onFailure { e ->
submitted = false submitted = false
Log.w(TAG, "Could not submit answers", e) 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 // The observer flips WAITING → REVEAL once the partner's answers land
// (or right away, if they finished first). // (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() { fun restart() {
observeJob?.cancel() observeJob?.cancel()
sessionId = null sessionId = null
@ -432,7 +440,8 @@ fun ThisOrThatScreen(
) )
TotPhase.ERROR -> ErrorState( TotPhase.ERROR -> ErrorState(
message = state.error ?: "Something didn't load. Go back and try again.", 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( TotPhase.WAITING -> WaitingForRevealScreen(
partnerName = state.partnerName, partnerName = state.partnerName,
@ -1141,7 +1150,7 @@ private fun PickChip(
} }
@Composable @Composable
private fun ErrorState(message: String, onBack: () -> Unit) { private fun ErrorState(message: String, onBack: () -> Unit, onRetry: (() -> Unit)? = null) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -1157,6 +1166,10 @@ private fun ErrorState(message: String, onBack: () -> Unit) {
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
if (onRetry != null) {
Button(onClick = onRetry) { Text("Try again") }
Spacer(Modifier.height(8.dp))
}
OutlinedButton(onClick = onBack) { OutlinedButton(onClick = onBack) {
Text("Back") Text("Back")
} }

View File

@ -62,8 +62,8 @@ class ChallengeStateMachineTest {
val state = ChallengeStateMachine.compute(input) val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.NOT_STARTED, state.state) assertEquals(ChallengeStatus.NOT_STARTED, state.state)
assertEquals("Start a 7-day rhythm together.", state.copy) assertEquals("Your 7-day challenge is ready — do today's step and mark it done.", state.copy)
assertEquals("Start challenge", state.cta) assertEquals("I did it today", state.cta)
assertTrue(state.canAdvance) assertTrue(state.canAdvance)
} }
@ -82,7 +82,7 @@ class ChallengeStateMachineTest {
val state = ChallengeStateMachine.compute(input) val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.STARTED_TODAY, state.state) 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) assertEquals("I did it today", state.cta)
assertTrue(state.canAdvance) assertTrue(state.canAdvance)
} }
@ -103,7 +103,7 @@ class ChallengeStateMachineTest {
assertEquals(ChallengeStatus.WAITING_FOR_PARTNER, state.state) assertEquals(ChallengeStatus.WAITING_FOR_PARTNER, state.state)
assertEquals("You did your part today. Waiting for your partner.", state.copy) 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) assertFalse(state.canAdvance)
} }