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.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,11 +151,19 @@ private fun AnswerRevealContent(
|
|||
onAnswerQuestion = onAnswerQuestion,
|
||||
onHome = onHome
|
||||
)
|
||||
state.answer.isRevealed -> AnimatedVisibility(
|
||||
!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,
|
||||
|
|
@ -143,17 +171,24 @@ private fun AnswerRevealContent(
|
|||
onHistory = onHistory,
|
||||
onHome = onHome
|
||||
)
|
||||
}
|
||||
else -> ReadyToRevealState(
|
||||
answer = state.answer,
|
||||
partnerAnswer = state.partnerAnswer,
|
||||
question = state.question,
|
||||
onReveal = onReveal,
|
||||
onHistory = onHistory
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -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
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FollowUpOption> = 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<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