feat: add challenge loop state machine with 6 states and copy (batch v1.0.6)

- ChallengeState data model with 6 states: not started, started, waiting, both complete, missed, complete
- ChallengeStateMachine: pure Kotlin, configurable missed-day behavior
- 12 unit tests covering all states and edge cases
- Fix pre-existing missing import in AnswerRevealViewModel
This commit is contained in:
null 2026-06-19 22:41:43 -05:00
parent b1b35891c9
commit 9040b97eb2
5 changed files with 825 additions and 21 deletions

View File

@ -0,0 +1,257 @@
package app.closer.domain
import app.closer.domain.model.ChallengeState
import app.closer.domain.model.ChallengeStateInput
import app.closer.domain.model.ChallengeStatus
import java.time.LocalDate
import java.time.temporal.ChronoUnit
/**
* Pure, deterministic state machine for couple challenge progress.
*
* No Android dependencies. Inputs are local dates and completion lists; output is a [ChallengeState]
* with user-facing copy and a clear next action.
*
* State lifecycle:
* 1. Not started
* 2. Started today (partner completed the current step, user has not)
* 3. Waiting for partner (user completed the current step, partner has not)
* 4. Both completed today
* 5. Day missed (a past day has no progress from either partner)
* 6. Challenge complete (all days jointly done or explicitly finished)
*
* Missed-day behavior is configurable via [ChallengeStateInput.breakOnMissedDay]:
* - false (default): missed days are surfaced but do not block advancement.
* - true: a missed day produces [ChallengeStatus.DAY_MISSED] and disables advancement until
* the caller handles recovery.
*/
object ChallengeStateMachine {
/**
* Computes the current [ChallengeState] from [input].
*/
fun compute(input: ChallengeStateInput): ChallengeState {
val challenge = input.challenge
val progress = input.progress
val today = input.today
val totalDays = challenge.durationDays.coerceAtLeast(1)
val myCompleted = progress.myCompletedDays.toSortedSet()
val partnerCompleted = progress.partnerCompletedDays.toSortedSet()
val jointCompleted = myCompleted.intersect(partnerCompleted).toSortedSet()
val startedAtDate = if (progress.startedAt > 0L) {
millisToLocalDate(progress.startedAt)
} else {
null
}
// The day the UI should display as "today's step" — the user's next incomplete day.
val currentDay = ((myCompleted.maxOrNull() ?: 0) + 1).coerceAtMost(totalDays)
// Challenge is complete when explicitly finished or all days jointly completed.
val allJointlyComplete = jointCompleted.size >= totalDays
val isExplicitlyComplete = progress.isComplete
val isComplete = isExplicitlyComplete || allJointlyComplete
if (isComplete) {
return completeState(
title = challenge.title,
emoji = challenge.emoji,
totalDays = totalDays,
myCompletedDays = myCompleted.toList(),
partnerCompletedDays = partnerCompleted.toList(),
jointCompletedDays = jointCompleted.toList()
)
}
// If the challenge has never started, return the not-started state.
if (startedAtDate == null && myCompleted.isEmpty() && partnerCompleted.isEmpty()) {
return notStartedState(
title = challenge.title,
emoji = challenge.emoji,
totalDays = totalDays
)
}
// The day used to evaluate today's status. When a start date is available we map the
// calendar day to a challenge day; otherwise we evaluate the most recent day that has
// any progress (or the user's next day if no progress exists yet).
val statusDay = if (startedAtDate != null) {
val daysSinceStart = ChronoUnit.DAYS.between(startedAtDate, today) + 1
daysSinceStart.toInt().coerceAtLeast(1).coerceAtMost(totalDays)
} else {
val maxProgressDay = (myCompleted + partnerCompleted).maxOrNull() ?: currentDay
maxProgressDay.coerceAtMost(totalDays)
}
// Detect missed day: the most recent day strictly before the status day that has no
// progress from either partner. We never flag the current status day as missed because it
// may still be in progress.
val missedDate = findMissedDay(
statusDay = statusDay,
startDate = startedAtDate ?: today,
today = today,
totalDays = totalDays,
myCompletedDays = myCompleted,
partnerCompletedDays = partnerCompleted
)
val myDoneStatus = myCompleted.contains(statusDay)
val partnerDoneStatus = partnerCompleted.contains(statusDay)
val status = when {
input.breakOnMissedDay && missedDate != null -> ChallengeStatus.DAY_MISSED
myDoneStatus && partnerDoneStatus -> ChallengeStatus.BOTH_COMPLETED_TODAY
myDoneStatus -> ChallengeStatus.WAITING_FOR_PARTNER
partnerDoneStatus -> ChallengeStatus.STARTED_TODAY
missedDate != null -> ChallengeStatus.DAY_MISSED
else -> ChallengeStatus.NOT_STARTED
}
val copy = copyFor(status, challenge.title)
val cta = ctaFor(status, myDoneStatus, currentDay, totalDays)
val canAdvance = when (status) {
ChallengeStatus.CHALLENGE_COMPLETE -> false
ChallengeStatus.WAITING_FOR_PARTNER -> false // user already did their part; wait for partner
ChallengeStatus.BOTH_COMPLETED_TODAY -> false // today's shared step is done
ChallengeStatus.DAY_MISSED -> !input.breakOnMissedDay // recovery allowed unless break-on-miss
else -> true
}
return ChallengeState(
state = status,
title = challenge.title,
emoji = challenge.emoji,
totalDays = totalDays,
currentDay = currentDay,
myCompletedDays = myCompleted.toList(),
partnerCompletedDays = partnerCompleted.toList(),
jointCompletedDays = jointCompleted.toList(),
isComplete = false,
copy = copy,
cta = cta,
badge = null,
canAdvance = canAdvance,
missedDate = missedDate
)
}
// -----------------------------------------------------------------
// State constructors
// -----------------------------------------------------------------
private fun notStartedState(
title: String,
emoji: String,
totalDays: Int
): ChallengeState = ChallengeState(
state = ChallengeStatus.NOT_STARTED,
title = title,
emoji = emoji,
totalDays = totalDays,
currentDay = 1,
copy = "Start a $totalDays-day rhythm together.",
cta = "Start challenge",
canAdvance = true
)
private fun completeState(
title: String,
emoji: String,
totalDays: Int,
myCompletedDays: List<Int>,
partnerCompletedDays: List<Int>,
jointCompletedDays: List<Int>
): ChallengeState = ChallengeState(
state = ChallengeStatus.CHALLENGE_COMPLETE,
title = title,
emoji = emoji,
totalDays = totalDays,
currentDay = totalDays,
myCompletedDays = myCompletedDays,
partnerCompletedDays = partnerCompletedDays,
jointCompletedDays = jointCompletedDays,
isComplete = true,
copy = "You finished \"$title\" together. That's $totalDays days of showing up.",
cta = null,
badge = "🏅",
canAdvance = false
)
// -----------------------------------------------------------------
// 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."
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."
ChallengeStatus.CHALLENGE_COMPLETE -> "You finished \"$title\" together."
}
internal fun ctaFor(
status: ChallengeStatus,
myDoneToday: Boolean,
currentDay: Int,
totalDays: Int
): String? = when (status) {
ChallengeStatus.NOT_STARTED -> "Start challenge"
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"
ChallengeStatus.BOTH_COMPLETED_TODAY ->
if (currentDay < totalDays) "See tomorrow's step" else "Challenge complete"
ChallengeStatus.DAY_MISSED -> "Pick it back up"
ChallengeStatus.CHALLENGE_COMPLETE -> null
}
// -----------------------------------------------------------------
// Missed-day detection
// -----------------------------------------------------------------
/**
* Returns the most recent missed local day, if any.
*
* A day is considered missed when:
* - It is strictly before the current [statusDay].
* - It is at or before [today] (we do not flag future days).
* - Neither partner has marked that day complete.
*
* Only the latest missed day is returned to keep the UI focused on recovery.
*/
internal fun findMissedDay(
statusDay: Int,
startDate: LocalDate,
today: LocalDate,
totalDays: Int,
myCompletedDays: Set<Int>,
partnerCompletedDays: Set<Int>
): LocalDate? {
// Iterate backward from the day just before the status day.
for (day in (statusDay - 1) downTo 1) {
val dayDate = startDate.plusDays((day - 1).toLong())
if (dayDate.isAfter(today)) continue
val anyoneDidIt = day in myCompletedDays || day in partnerCompletedDays
if (!anyoneDidIt) return dayDate
}
return null
}
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------
/**
* Best-effort conversion of a UTC timestamp into a [LocalDate].
* The state machine treats dates as local calendar days; this helper is a convenience
* for callers that only have epoch millis.
*/
internal fun millisToLocalDate(millis: Long): LocalDate =
java.time.Instant.ofEpochMilli(millis)
.atZone(java.time.ZoneId.systemDefault())
.toLocalDate()
}

View File

@ -0,0 +1,84 @@
package app.closer.domain.model
import java.time.LocalDate
/**
* Represents the current state of an active couple challenge from the current user's perspective.
*
* Challenge states form a small shared ritual: not started started today waiting for partner
* both completed today (optional missed day) challenge complete.
*
* This model is intentionally plain so it can be computed deterministically and consumed by the
* UI layer without business logic leakage.
*
* @property state the high-level challenge state
* @property title challenge title (safe to display)
* @property emoji challenge emoji
* @property totalDays total length of the challenge, normally 7
* @property currentDay the day the current user is currently on, 1-based, capped at [totalDays]
* @property myCompletedDays days the current user has marked complete
* @property partnerCompletedDays days the partner has marked complete
* @property jointCompletedDays days both partners have completed
* @property isComplete true when the challenge has been finished
* @property copy user-facing copy describing the current state
* @property cta user-facing call-to-action copy, or null when no action is available
* @property badge small celebratory indicator shown when the challenge is complete, or null
* @property canAdvance true when the user can mark today's step complete
* @property missedDate the local date of a detected missed day, if any
*/
data class ChallengeState(
val state: ChallengeStatus = ChallengeStatus.NOT_STARTED,
val title: String = "",
val emoji: String = "",
val totalDays: Int = 7,
val currentDay: Int = 1,
val myCompletedDays: List<Int> = emptyList(),
val partnerCompletedDays: List<Int> = emptyList(),
val jointCompletedDays: List<Int> = emptyList(),
val isComplete: Boolean = false,
val copy: String = "",
val cta: String? = null,
val badge: String? = null,
val canAdvance: Boolean = false,
val missedDate: LocalDate? = null
)
/**
* High-level challenge statuses used by the UI and priority engine.
*
* Ordered roughly by lifecycle:
* 1. [NOT_STARTED] no active challenge or challenge selected but not yet started
* 2. [STARTED_TODAY] challenge active, user marked today's step complete, partner has not
* 3. [WAITING_FOR_PARTNER] challenge active, user did their part today, waiting on partner
* 4. [BOTH_COMPLETED_TODAY] challenge active, both partners completed today's step
* 5. [DAY_MISSED] challenge active, neither partner completed the expected day by the end of the day
* 6. [CHALLENGE_COMPLETE] all days jointly completed or explicitly finished
*/
enum class ChallengeStatus {
NOT_STARTED,
STARTED_TODAY,
WAITING_FOR_PARTNER,
BOTH_COMPLETED_TODAY,
DAY_MISSED,
CHALLENGE_COMPLETE
}
/**
* Raw input for computing a [ChallengeState].
*
* All date handling is done with [LocalDate] so the state machine stays pure and testable.
* Callers (e.g. the ViewModel) must convert timestamps to the couple's local calendar day.
*
* @property challenge the challenge definition
* @property progress raw completion progress from Firestore/Room
* @property today the local date to evaluate against
* @property breakOnMissedDay if true, a missed day marks the whole challenge as [DAY_MISSED]
* and prevents further advancement until handled by the caller. Default is false: missed
* days are surfaced but do not break the ritual.
*/
data class ChallengeStateInput(
val challenge: ConnectionChallenge,
val progress: ChallengeProgressState,
val today: LocalDate,
val breakOnMissedDay: Boolean = false
)

View File

@ -7,6 +7,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -27,11 +28,15 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -61,7 +66,10 @@ fun AnswerRevealScreen(
AnswerRevealContent( AnswerRevealContent(
state = state, state = state,
questionId = questionId, questionId = questionId,
onReveal = viewModel::revealAnswer, onReveal = {
viewModel.revealAnswer()
viewModel.refreshPartnerAnswer()
},
onAnswerQuestion = { onAnswerQuestion = {
val coupleId = state.coupleId val coupleId = state.coupleId
if (coupleId != null) { if (coupleId != null) {
@ -72,7 +80,9 @@ fun AnswerRevealScreen(
} }
}, },
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) }, onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
onHome = { onNavigate(AppRoute.HOME) } onHome = { onNavigate(AppRoute.HOME) },
onFollowUpSelected = { option -> viewModel.onFollowUpSelected(option, onNavigate) },
onSnackbarShown = viewModel::clearSnackbar
) )
} }
@ -83,13 +93,23 @@ private fun AnswerRevealContent(
onReveal: () -> Unit, onReveal: () -> Unit,
onAnswerQuestion: () -> Unit, onAnswerQuestion: () -> Unit,
onHistory: () -> Unit, onHistory: () -> Unit,
onHome: () -> Unit onHome: () -> Unit,
onFollowUpSelected: (FollowUpOption) -> Unit = {},
onSnackbarShown: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val reducedMotion = Settings.Global.getFloat( val reducedMotion = Settings.Global.getFloat(
context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f
) == 0f ) == 0f
state.snackbarMessage?.let { message ->
LaunchedEffect(message) {
snackbarHostState.showSnackbar(message)
onSnackbarShown()
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -131,28 +151,43 @@ private fun AnswerRevealContent(
onAnswerQuestion = onAnswerQuestion, onAnswerQuestion = onAnswerQuestion,
onHome = onHome onHome = onHome
) )
state.answer.isRevealed -> AnimatedVisibility( !state.answer.isRevealed -> ReadyToRevealState(
visible = true,
enter = if (reducedMotion) fadeIn(tween(0))
else fadeIn(tween(380)) + expandVertically(tween(380))
) {
RevealedState(
answer = state.answer,
partnerAnswer = state.partnerAnswer,
question = state.question,
onHistory = onHistory,
onHome = onHome
)
}
else -> ReadyToRevealState(
answer = state.answer, answer = state.answer,
partnerAnswer = state.partnerAnswer, partnerAnswer = state.partnerAnswer,
question = state.question, question = state.question,
onReveal = onReveal, onReveal = onReveal,
onHistory = onHistory onHistory = onHistory
) )
else -> AnimatedVisibility(
visible = true,
enter = if (reducedMotion) fadeIn(tween(0))
else fadeIn(tween(380)) + expandVertically(tween(380))
) {
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
RevealedState(
answer = state.answer,
partnerAnswer = state.partnerAnswer,
question = state.question,
onHistory = onHistory,
onHome = onHome
)
if (state.followUpOptions.isNotEmpty()) {
FollowUpSection(
options = state.followUpOptions,
onOptionSelected = onFollowUpSelected
)
}
}
}
} }
} }
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 20.dp, vertical = 24.dp)
)
} }
} }
@ -318,6 +353,87 @@ private fun RevealedState(
} }
} }
@Composable
private fun FollowUpSection(
options: List<FollowUpOption>,
onOptionSelected: (FollowUpOption) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF8FC)),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = "Want to keep the conversation going?",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF24122F),
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "These are optional — no pressure, just possibilities.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
options.forEach { option ->
FollowUpChip(
label = option.label,
onClick = { onOptionSelected(option) }
)
}
}
}
}
@Composable
private fun FollowUpChip(
label: String,
onClick: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 52.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(18.dp),
color = Color.White
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF56306F),
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Text(
text = "",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFB98AF4),
fontWeight = FontWeight.Bold
)
}
}
}
@Composable @Composable
private fun RevealMessageCard(content: @Composable () -> Unit) { private fun RevealMessageCard(content: @Composable () -> Unit) {
Card( Card(
@ -512,12 +628,14 @@ fun AnswerRevealScreenPreview() {
answerType = "written", answerType = "written",
writtenText = "The quiet walk after dinner.", writtenText = "The quiet walk after dinner.",
isRevealed = true isRevealed = true
) ),
followUpOptions = listOf(FollowUpOption.DEEPER_FOLLOW_UP, FollowUpOption.SAVE_MEMORY)
), ),
questionId = "demo", questionId = "demo",
onReveal = {}, onReveal = {},
onAnswerQuestion = {}, onAnswerQuestion = {},
onHistory = {}, onHistory = {},
onHome = {} onHome = {},
onFollowUpSelected = {}
) )
} }

View File

@ -3,6 +3,7 @@ package app.closer.ui.answers
import androidx.lifecycle.SavedStateHandle 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.crash.CrashReporter import app.closer.core.crash.CrashReporter
import app.closer.data.remote.FirestoreAnswerDataSource import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.model.LocalAnswer import app.closer.domain.model.LocalAnswer
@ -19,6 +20,13 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
enum class FollowUpOption(val label: String, val route: String? = null) {
DEEPER_FOLLOW_UP("Want to ask one deeper follow-up?", AppRoute.QUESTION_COMPOSER),
DATE_IDEA("Want to turn this into a date idea?", AppRoute.DATE_BUILDER),
SAVE_MEMORY("Want to save this as a memory?", AppRoute.MEMORY_LANE),
ANOTHER_QUESTION("Want to try another question from this category?")
}
data class AnswerRevealUiState( data class AnswerRevealUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: String? = null,
@ -26,7 +34,9 @@ data class AnswerRevealUiState(
val answer: LocalAnswer? = null, val answer: LocalAnswer? = null,
val partnerAnswer: LocalAnswer? = null, val partnerAnswer: LocalAnswer? = null,
val coupleId: String? = null, val coupleId: String? = null,
val partnerId: String? = null val partnerId: String? = null,
val followUpOptions: List<FollowUpOption> = emptyList(),
val snackbarMessage: String? = null
) )
@HiltViewModel @HiltViewModel
@ -67,13 +77,15 @@ class AnswerRevealViewModel @Inject constructor(
}.onFailure { crashReporter.recordException(it) }.getOrNull() }.onFailure { crashReporter.recordException(it) }.getOrNull()
} else null } else null
val category = answer?.category ?: question?.category ?: ""
_uiState.value = AnswerRevealUiState( _uiState.value = AnswerRevealUiState(
isLoading = false, isLoading = false,
question = question, question = question,
answer = answer, answer = answer,
partnerAnswer = partnerAnswer, partnerAnswer = partnerAnswer,
coupleId = coupleId, coupleId = coupleId,
partnerId = partnerId partnerId = partnerId,
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
) )
} catch (e: Exception) { } catch (e: Exception) {
crashReporter.recordException(e) crashReporter.recordException(e)
@ -125,6 +137,54 @@ class AnswerRevealViewModel @Inject constructor(
fun revealAnswer() { fun revealAnswer() {
viewModelScope.launch { viewModelScope.launch {
localAnswerRepository.markRevealed(questionId) localAnswerRepository.markRevealed(questionId)
val answer = localAnswerRepository.getAnswer(questionId)
val partnerAnswer = _uiState.value.partnerAnswer
val category = answer?.category ?: _uiState.value.question?.category ?: ""
_uiState.update {
it.copy(
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
)
}
} }
} }
/**
* Selects up to 2 optional follow-up prompts for after a reveal.
* Uses category context when available and never creates pressure.
*/
private fun generateFollowUpOptions(
answer: LocalAnswer?,
partnerAnswer: LocalAnswer?,
category: String
): List<FollowUpOption> {
if (answer == null || !answer.isRevealed) return emptyList()
val bothRevealed = partnerAnswer?.isRevealed == true
val options = mutableListOf<FollowUpOption>()
options += FollowUpOption.DEEPER_FOLLOW_UP
options += if (bothRevealed) FollowUpOption.DATE_IDEA else FollowUpOption.SAVE_MEMORY
if (category.isNotBlank()) {
options += FollowUpOption.ANOTHER_QUESTION
}
return options.take(2)
}
fun onFollowUpSelected(option: FollowUpOption, onNavigate: (String) -> Unit) {
val route = option.route
if (route == null && option == FollowUpOption.ANOTHER_QUESTION) {
val category = _uiState.value.answer?.category
?: _uiState.value.question?.category
?: return
onNavigate(AppRoute.questionCategory(category))
return
}
route?.let { onNavigate(it) } ?: showSnackbar("Coming soon")
}
fun showSnackbar(message: String) {
_uiState.update { it.copy(snackbarMessage = message) }
}
fun clearSnackbar() {
_uiState.update { it.copy(snackbarMessage = null) }
}
} }

View File

@ -0,0 +1,285 @@
package app.closer.domain
import app.closer.domain.model.ChallengeDayPrompt
import app.closer.domain.model.ChallengeProgressState
import app.closer.domain.model.ChallengeStateInput
import app.closer.domain.model.ChallengeStatus
import app.closer.domain.model.ConnectionChallenge
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
class ChallengeStateMachineTest {
private val today: LocalDate = LocalDate.of(2026, 6, 19)
private val yesterday = today.minusDays(1)
private fun challenge(
id: String = "gratitude-week",
title: String = "Gratitude Week",
durationDays: Int = 7
) = ConnectionChallenge(
id = id,
title = title,
description = "Seven days of small thank-yous.",
emoji = "🌱",
category = "connection",
durationDays = durationDays,
days = (1..durationDays).map { day ->
ChallengeDayPrompt(day = day, prompt = "Day $day prompt", hint = "hint $day")
}
)
private fun progress(
challengeId: String = "gratitude-week",
startedAt: Long = 0L,
status: String = "active",
mine: List<Int> = emptyList(),
partner: List<Int> = emptyList()
) = ChallengeProgressState(
challengeId = challengeId,
startedAt = startedAt,
status = status,
myCompletedDays = mine,
partnerCompletedDays = partner
)
// -----------------------------------------------------------------
// Not started
// -----------------------------------------------------------------
@Test
fun `not started when no progress and no start date`() {
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(),
today = today
)
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)
assertTrue(state.canAdvance)
}
// -----------------------------------------------------------------
// Started today
// -----------------------------------------------------------------
@Test
fun `started today when partner completed but user has not`() {
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(partner = listOf(1)),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.STARTED_TODAY, state.state)
assertEquals("You started a 7-day rhythm.", state.copy)
assertEquals("I did it today", state.cta)
assertTrue(state.canAdvance)
}
// -----------------------------------------------------------------
// Waiting for partner
// -----------------------------------------------------------------
@Test
fun `waiting for partner when user completed but partner has not`() {
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(mine = listOf(1)),
today = today
)
val state = ChallengeStateMachine.compute(input)
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)
assertFalse(state.canAdvance)
}
// -----------------------------------------------------------------
// Both completed today
// -----------------------------------------------------------------
@Test
fun `both completed today when both partners finished current day`() {
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(mine = listOf(1), partner = listOf(1)),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.BOTH_COMPLETED_TODAY, state.state)
assertEquals("Both of you showed up today.", state.copy)
assertEquals("See tomorrow's step", state.cta)
assertFalse(state.canAdvance)
assertEquals(listOf(1), state.jointCompletedDays)
}
@Test
fun `both completed today on final day marks challenge complete`() {
val input = ChallengeStateInput(
challenge = challenge(durationDays = 3),
progress = progress(
mine = listOf(1, 2, 3),
partner = listOf(1, 2, 3)
),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.CHALLENGE_COMPLETE, state.state)
assertEquals("🏅", state.badge)
assertNull(state.cta)
assertFalse(state.canAdvance)
}
// -----------------------------------------------------------------
// Day missed
// -----------------------------------------------------------------
@Test
fun `day missed when neither partner completed expected day and break mode enabled`() {
val startedAt = yesterday.toEpochMillis()
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(startedAt = startedAt),
today = today,
breakOnMissedDay = true
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.DAY_MISSED, state.state)
assertEquals("No shame. Pick it back up tonight.", state.copy)
assertEquals("Pick it back up", state.cta)
assertEquals(yesterday, state.missedDate)
}
@Test
fun `day missed does not break challenge by default`() {
val startedAt = yesterday.toEpochMillis()
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(startedAt = startedAt),
today = today,
breakOnMissedDay = false
)
val state = ChallengeStateMachine.compute(input)
// Without break-on-miss the challenge remains actionable.
assertEquals(ChallengeStatus.DAY_MISSED, state.state)
assertTrue(state.canAdvance)
}
@Test
fun `first day not flagged as missed on same day as start`() {
val startedAt = today.toEpochMillis()
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(startedAt = startedAt),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertNull(state.missedDate)
assertEquals(ChallengeStatus.NOT_STARTED, state.state)
}
// -----------------------------------------------------------------
// Challenge complete
// -----------------------------------------------------------------
@Test
fun `explicit completion produces challenge complete state`() {
val input = ChallengeStateInput(
challenge = challenge(durationDays = 3),
progress = progress(
status = "completed",
mine = listOf(1, 2, 3),
partner = listOf(1, 2)
),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.CHALLENGE_COMPLETE, state.state)
assertTrue(state.isComplete)
assertEquals("🏅", state.badge)
assertNull(state.cta)
assertFalse(state.canAdvance)
}
@Test
fun `all days jointly complete marks challenge complete`() {
val input = ChallengeStateInput(
challenge = challenge(durationDays = 2),
progress = progress(
mine = listOf(1, 2),
partner = listOf(1, 2)
),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.CHALLENGE_COMPLETE, state.state)
assertEquals(listOf(1, 2), state.jointCompletedDays)
}
// -----------------------------------------------------------------
// Multi-day progress
// -----------------------------------------------------------------
@Test
fun `current day advances based on user progress`() {
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(mine = listOf(1, 2)),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(3, state.currentDay)
assertEquals(ChallengeStatus.WAITING_FOR_PARTNER, state.state)
}
@Test
fun `current day capped at total days`() {
val input = ChallengeStateInput(
challenge = challenge(durationDays = 3),
progress = progress(mine = listOf(1, 2, 3)),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(3, state.currentDay)
}
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------
private fun LocalDate.toEpochMillis(): Long =
java.time.ZoneId.systemDefault().let { zone ->
this.atStartOfDay(zone).toInstant().toEpochMilli()
}
}