feat: improve daily question habit loop with 5 UI states (batch v1.0.1)

- Add DailyQuestionState enum to HomeViewModel with 5 states
- Real-time Firestore listener for partner's daily answer
- Home card shows correct copy/CTA per state
- CTAs: answer, gentle reminder (no-op), reveal, follow-up (placeholder)
- Cleanup listener on onCleared()
This commit is contained in:
null 2026-06-19 22:23:24 -05:00
parent aff1150295
commit 0b619ee7ba
2 changed files with 118 additions and 17 deletions

View File

@ -98,10 +98,36 @@ fun HomeScreen(
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
onSettings = { onNavigate(AppRoute.SETTINGS) },
onInvite = { onNavigate(AppRoute.CREATE_INVITE) },
onReminder = viewModel::sendGentleReminder,
onReveal = { state.dailyQuestion?.id?.let { onNavigate(AppRoute.answerReveal(it)) } },
onFollowUp = { state.dailyQuestion?.let { onNavigate(AppRoute.questionThread(state.coupleId ?: "", it.id)) } },
onRefresh = viewModel::loadHome
)
}
data class HomeCallbacks(
val onDailyQuestion: () -> Unit,
val onReminder: () -> Unit,
val onReveal: () -> Unit,
val onFollowUp: () -> Unit,
val onPacks: () -> Unit,
val onCategory: (String) -> Unit,
val onHistory: () -> Unit,
val onSettings: () -> Unit,
val onInvite: () -> Unit,
val onRefresh: () -> Unit
)
private fun HomeCallbacks.toActionHandler(): (HomeAction) -> Unit = { action ->
when (action.target) {
HomeActionTarget.InvitePartner -> onInvite()
HomeActionTarget.DailyQuestion -> onDailyQuestion()
HomeActionTarget.AnswerHistory -> onHistory()
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
HomeActionTarget.Settings -> onSettings()
}
}
@Composable
private fun HomeContent(
state: HomeUiState,
@ -112,8 +138,29 @@ private fun HomeContent(
onHistory: () -> Unit,
onSettings: () -> Unit,
onInvite: () -> Unit,
onReminder: () -> Unit,
onReveal: () -> Unit,
onFollowUp: () -> Unit,
onRefresh: () -> Unit
) {
val callbacks = remember(
onDailyQuestion, onReminder, onReveal, onFollowUp,
onPacks, onCategory, onHistory, onSettings, onInvite, onRefresh
) {
HomeCallbacks(
onDailyQuestion = onDailyQuestion,
onReminder = onReminder,
onReveal = onReveal,
onFollowUp = onFollowUp,
onPacks = onPacks,
onCategory = onCategory,
onHistory = onHistory,
onSettings = onSettings,
onInvite = onInvite,
onRefresh = onRefresh
)
}
val onActionSelected = callbacks.toActionHandler()
Box(
modifier = Modifier
.fillMaxSize()
@ -145,24 +192,17 @@ private fun HomeContent(
state.isLoading -> LoadingHomeCard()
state.error != null -> ErrorHomeCard(message = state.error, onRefresh = onRefresh)
else -> {
val onActionSelected: (HomeAction) -> Unit = { action ->
when (action.target) {
HomeActionTarget.InvitePartner -> onInvite()
HomeActionTarget.DailyQuestion -> onDailyQuestion()
HomeActionTarget.AnswerHistory -> onHistory()
HomeActionTarget.QuestionPacks -> {
action.categoryId?.let(onCategory) ?: onPacks()
}
HomeActionTarget.Settings -> onSettings()
}
}
state.primaryAction?.let { action ->
PrimaryHomeActionCard(
action = action,
stats = state.answerStats,
streakCount = state.streakCount,
onAction = onActionSelected
onAction = onActionSelected,
onReminder = callbacks.onReminder,
onReveal = callbacks.onReveal,
onFollowUp = callbacks.onFollowUp,
dailyQuestionState = state.dailyQuestionState,
dailyQuestion = state.dailyQuestion
)
}
@ -223,10 +263,55 @@ private fun PrimaryHomeActionCard(
action: HomeAction,
stats: HomeAnswerStats,
streakCount: Int,
onAction: (HomeAction) -> Unit
onAction: (HomeAction) -> Unit,
onReminder: () -> Unit,
onReveal: () -> Unit,
onFollowUp: () -> Unit,
dailyQuestionState: DailyQuestionState,
dailyQuestion: Question?
) {
val colors = action.tone.actionColors()
// For daily-question actions, route the CTA through the explicit state handlers
// so the same button label maps to the correct next step (answer, remind,
// reveal, or follow-up) even if the action target is still DailyQuestion.
val ctaClick = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> onReminder
DailyQuestionState.BOTH_ANSWERED -> onReveal
DailyQuestionState.REVEALED -> onFollowUp
else -> { { onAction(action) } }
}
else -> { { onAction(action) } }
}
val titleOverride = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.UNANSWERED -> "Tonight's question is ready."
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "You showed up tonight. Waiting for your partner."
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "Your partner answered. Your turn."
DailyQuestionState.BOTH_ANSWERED -> "Reveal is ready."
DailyQuestionState.REVEALED -> "You opened a conversation tonight."
}
else -> action.title
}
val bodyOverride = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.UNANSWERED ->
dailyQuestion?.text ?: "Answer tonight's question privately, then choose when to share."
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING ->
"Your answer is private until they answer too. No pressure — the reveal waits for both of you."
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING ->
"Answer to unlock the reveal. Your response stays private until you are ready."
DailyQuestionState.BOTH_ANSWERED ->
"Both of you answered. Open it together when you are both in the right headspace."
DailyQuestionState.REVEALED ->
"You revealed an answer together. What comes next is up to both of you."
}
else -> action.body
}
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.FeatureCard),
@ -278,14 +363,14 @@ private fun PrimaryHomeActionCard(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = action.title,
text = titleOverride,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = action.body,
text = bodyOverride,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4D4354),
maxLines = 4,
@ -298,7 +383,7 @@ private fun PrimaryHomeActionCard(
CloserActionButton(
label = action.cta,
onClick = { onAction(action) },
onClick = ctaClick,
modifier = Modifier.fillMaxWidth(),
containerColor = colors.accent,
contentColor = colors.onAccent
@ -696,6 +781,9 @@ fun HomeScreenPreview() {
),
snackbarHostState = remember { SnackbarHostState() },
onDailyQuestion = {},
onReminder = {},
onReveal = {},
onFollowUp = {},
onPacks = {},
onCategory = {},
onHistory = {},

View File

@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.EncryptionStatus
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
@ -86,6 +87,7 @@ data class HomeUiState(
val partnerLeftEvent: Boolean = false,
val needsRecovery: Boolean = false,
val needsEncryptionUpgrade: Boolean = false,
val coupleId: String? = null,
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
val hasPartnerAnsweredToday: Boolean = false,
val partnerAnsweredQuestionId: String? = null,
@ -119,6 +121,8 @@ class HomeViewModel @Inject constructor(
super.onCleared()
coupleStateListener?.remove()
coupleStateListener = null
partnerAnswerListener?.remove()
partnerAnswerListener = null
}
fun loadHome() {
@ -157,6 +161,7 @@ class HomeViewModel @Inject constructor(
partnerName = partnerName,
streakCount = couple?.streakCount ?: 0,
isPaired = couple != null,
coupleId = couple?.id,
partnerLeftEvent = false,
needsRecovery = needsRecovery,
needsEncryptionUpgrade = needsEncryptionUpgrade
@ -217,6 +222,14 @@ class HomeViewModel @Inject constructor(
loadHome()
}
/**
* Sends a gentle reminder to the partner that the daily question is waiting.
* Notification wiring is intentionally a no-op until Batch 6.
*/
fun sendGentleReminder() {
// TODO(Batch 6): wire partner-triggered notification via FCM.
}
private fun observeAnswers() {
viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers ->