From 9040b97eb29636c163239d35e318329fe641eeae Mon Sep 17 00:00:00 2001 From: null Date: Fri, 19 Jun 2026 22:41:43 -0500 Subject: [PATCH] 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 --- .../closer/domain/ChallengeStateMachine.kt | 257 ++++++++++++++++ .../app/closer/domain/model/ChallengeState.kt | 84 ++++++ .../closer/ui/answers/AnswerRevealScreen.kt | 156 ++++++++-- .../ui/answers/AnswerRevealViewModel.kt | 64 +++- .../domain/ChallengeStateMachineTest.kt | 285 ++++++++++++++++++ 5 files changed, 825 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/app/closer/domain/ChallengeStateMachine.kt create mode 100644 app/src/main/java/app/closer/domain/model/ChallengeState.kt create mode 100644 app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt diff --git a/app/src/main/java/app/closer/domain/ChallengeStateMachine.kt b/app/src/main/java/app/closer/domain/ChallengeStateMachine.kt new file mode 100644 index 00000000..d55c893b --- /dev/null +++ b/app/src/main/java/app/closer/domain/ChallengeStateMachine.kt @@ -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, + partnerCompletedDays: List, + jointCompletedDays: List + ): 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, + partnerCompletedDays: Set + ): 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() +} diff --git a/app/src/main/java/app/closer/domain/model/ChallengeState.kt b/app/src/main/java/app/closer/domain/model/ChallengeState.kt new file mode 100644 index 00000000..5d0ebeb6 --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/ChallengeState.kt @@ -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 = emptyList(), + val partnerCompletedDays: List = emptyList(), + val jointCompletedDays: List = 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 +) diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt index 88815ef0..e457e8e0 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,11 +28,15 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier @@ -61,7 +66,10 @@ fun AnswerRevealScreen( AnswerRevealContent( state = state, questionId = questionId, - onReveal = viewModel::revealAnswer, + onReveal = { + viewModel.revealAnswer() + viewModel.refreshPartnerAnswer() + }, onAnswerQuestion = { val coupleId = state.coupleId if (coupleId != null) { @@ -72,7 +80,9 @@ fun AnswerRevealScreen( } }, 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, onAnswerQuestion: () -> Unit, onHistory: () -> Unit, - onHome: () -> Unit + onHome: () -> Unit, + onFollowUpSelected: (FollowUpOption) -> Unit = {}, + onSnackbarShown: () -> Unit = {} ) { val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } val reducedMotion = Settings.Global.getFloat( context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f ) == 0f + state.snackbarMessage?.let { message -> + LaunchedEffect(message) { + snackbarHostState.showSnackbar(message) + onSnackbarShown() + } + } + Box( modifier = Modifier .fillMaxSize() @@ -131,28 +151,43 @@ private fun AnswerRevealContent( onAnswerQuestion = onAnswerQuestion, onHome = onHome ) - state.answer.isRevealed -> AnimatedVisibility( - 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( + !state.answer.isRevealed -> ReadyToRevealState( answer = state.answer, partnerAnswer = state.partnerAnswer, question = state.question, onReveal = onReveal, 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, + 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 private fun RevealMessageCard(content: @Composable () -> Unit) { Card( @@ -512,12 +628,14 @@ fun AnswerRevealScreenPreview() { answerType = "written", writtenText = "The quiet walk after dinner.", isRevealed = true - ) + ), + followUpOptions = listOf(FollowUpOption.DEEPER_FOLLOW_UP, FollowUpOption.SAVE_MEMORY) ), questionId = "demo", onReveal = {}, onAnswerQuestion = {}, onHistory = {}, - onHome = {} + onHome = {}, + onFollowUpSelected = {} ) } diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt index a6c692a0..fa57cd6b 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt @@ -3,6 +3,7 @@ package app.closer.ui.answers import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.closer.core.navigation.AppRoute import app.closer.core.crash.CrashReporter import app.closer.data.remote.FirestoreAnswerDataSource import app.closer.domain.model.LocalAnswer @@ -19,6 +20,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update 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( val isLoading: Boolean = true, val error: String? = null, @@ -26,7 +34,9 @@ data class AnswerRevealUiState( val answer: LocalAnswer? = null, val partnerAnswer: LocalAnswer? = null, val coupleId: String? = null, - val partnerId: String? = null + val partnerId: String? = null, + val followUpOptions: List = emptyList(), + val snackbarMessage: String? = null ) @HiltViewModel @@ -67,13 +77,15 @@ class AnswerRevealViewModel @Inject constructor( }.onFailure { crashReporter.recordException(it) }.getOrNull() } else null + val category = answer?.category ?: question?.category ?: "" _uiState.value = AnswerRevealUiState( isLoading = false, question = question, answer = answer, partnerAnswer = partnerAnswer, coupleId = coupleId, - partnerId = partnerId + partnerId = partnerId, + followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category) ) } catch (e: Exception) { crashReporter.recordException(e) @@ -125,6 +137,54 @@ class AnswerRevealViewModel @Inject constructor( fun revealAnswer() { viewModelScope.launch { 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 { + if (answer == null || !answer.isRevealed) return emptyList() + val bothRevealed = partnerAnswer?.isRevealed == true + val options = mutableListOf() + 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) } + } } diff --git a/app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt b/app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt new file mode 100644 index 00000000..8063a78a --- /dev/null +++ b/app/src/test/java/app/closer/domain/ChallengeStateMachineTest.kt @@ -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 = emptyList(), + partner: List = 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() + } +}