From edfef1b6ca0436b9bef489152bd967dbd0553233 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 22 Jun 2026 22:02:39 -0500 Subject: [PATCH] feat: challenge state machine, game screen updates, state machine tests --- .../closer/domain/ChallengeStateMachine.kt | 19 +++++++------ .../challenges/ConnectionChallengesScreen.kt | 27 +++++++++++++++---- .../closer/ui/desiresync/DesireSyncScreen.kt | 19 ++++++++++--- .../app/closer/ui/howwell/HowWellScreen.kt | 18 ++++++++++--- .../closer/ui/thisorthat/ThisOrThatScreen.kt | 19 ++++++++++--- .../domain/ChallengeStateMachineTest.kt | 8 +++--- 6 files changed, 82 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/app/closer/domain/ChallengeStateMachine.kt b/app/src/main/java/app/closer/domain/ChallengeStateMachine.kt index d55c893b..e40bd917 100644 --- a/app/src/main/java/app/closer/domain/ChallengeStateMachine.kt +++ b/app/src/main/java/app/closer/domain/ChallengeStateMachine.kt @@ -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" diff --git a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt index eec6057a..b6ec4171 100644 --- a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt +++ b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt @@ -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), diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index e7e0180b..bb3342cc 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -99,6 +99,7 @@ data class DesireSyncUiState( val matches: List = 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") } diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index e526b3bd..4908086b 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -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") } diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index d96140ea..62150516 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -139,6 +139,7 @@ data class ThisOrThatUiState( val revealCards: List = 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") } diff --git a/app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt b/app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt index 8063a78a..d389176c 100644 --- a/app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt +++ b/app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt @@ -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) }