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:
parent
b1b35891c9
commit
9040b97eb2
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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 = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue