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
}
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"

View File

@ -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),

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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)
}