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 2649cc193b
commit b570e32217
2 changed files with 118 additions and 17 deletions

View File

@ -98,10 +98,36 @@ fun HomeScreen(
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) }, onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
onSettings = { onNavigate(AppRoute.SETTINGS) }, onSettings = { onNavigate(AppRoute.SETTINGS) },
onInvite = { onNavigate(AppRoute.CREATE_INVITE) }, 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 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 @Composable
private fun HomeContent( private fun HomeContent(
state: HomeUiState, state: HomeUiState,
@ -112,8 +138,29 @@ private fun HomeContent(
onHistory: () -> Unit, onHistory: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
onInvite: () -> Unit, onInvite: () -> Unit,
onReminder: () -> Unit,
onReveal: () -> Unit,
onFollowUp: () -> Unit,
onRefresh: () -> 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -145,24 +192,17 @@ private fun HomeContent(
state.isLoading -> LoadingHomeCard() state.isLoading -> LoadingHomeCard()
state.error != null -> ErrorHomeCard(message = state.error, onRefresh = onRefresh) state.error != null -> ErrorHomeCard(message = state.error, onRefresh = onRefresh)
else -> { 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 -> state.primaryAction?.let { action ->
PrimaryHomeActionCard( PrimaryHomeActionCard(
action = action, action = action,
stats = state.answerStats, stats = state.answerStats,
streakCount = state.streakCount, 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, action: HomeAction,
stats: HomeAnswerStats, stats: HomeAnswerStats,
streakCount: Int, streakCount: Int,
onAction: (HomeAction) -> Unit onAction: (HomeAction) -> Unit,
onReminder: () -> Unit,
onReveal: () -> Unit,
onFollowUp: () -> Unit,
dailyQuestionState: DailyQuestionState,
dailyQuestion: Question?
) { ) {
val colors = action.tone.actionColors() 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( CloserCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.FeatureCard), shape = RoundedCornerShape(CloserRadii.FeatureCard),
@ -278,14 +363,14 @@ private fun PrimaryHomeActionCard(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text( Text(
text = action.title, text = titleOverride,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = action.body, text = bodyOverride,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4D4354), color = Color(0xFF4D4354),
maxLines = 4, maxLines = 4,
@ -298,7 +383,7 @@ private fun PrimaryHomeActionCard(
CloserActionButton( CloserActionButton(
label = action.cta, label = action.cta,
onClick = { onAction(action) }, onClick = ctaClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
containerColor = colors.accent, containerColor = colors.accent,
contentColor = colors.onAccent contentColor = colors.onAccent
@ -696,6 +781,9 @@ fun HomeScreenPreview() {
), ),
snackbarHostState = remember { SnackbarHostState() }, snackbarHostState = remember { SnackbarHostState() },
onDailyQuestion = {}, onDailyQuestion = {},
onReminder = {},
onReveal = {},
onFollowUp = {},
onPacks = {}, onPacks = {},
onCategory = {}, onCategory = {},
onHistory = {}, onHistory = {},

View File

@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.EncryptionStatus import app.closer.crypto.EncryptionStatus
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.model.LocalAnswer import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory import app.closer.domain.model.QuestionCategory
@ -86,6 +87,7 @@ data class HomeUiState(
val partnerLeftEvent: Boolean = false, val partnerLeftEvent: Boolean = false,
val needsRecovery: Boolean = false, val needsRecovery: Boolean = false,
val needsEncryptionUpgrade: Boolean = false, val needsEncryptionUpgrade: Boolean = false,
val coupleId: String? = null,
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED, val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
val hasPartnerAnsweredToday: Boolean = false, val hasPartnerAnsweredToday: Boolean = false,
val partnerAnsweredQuestionId: String? = null, val partnerAnsweredQuestionId: String? = null,
@ -119,6 +121,8 @@ class HomeViewModel @Inject constructor(
super.onCleared() super.onCleared()
coupleStateListener?.remove() coupleStateListener?.remove()
coupleStateListener = null coupleStateListener = null
partnerAnswerListener?.remove()
partnerAnswerListener = null
} }
fun loadHome() { fun loadHome() {
@ -157,6 +161,7 @@ class HomeViewModel @Inject constructor(
partnerName = partnerName, partnerName = partnerName,
streakCount = couple?.streakCount ?: 0, streakCount = couple?.streakCount ?: 0,
isPaired = couple != null, isPaired = couple != null,
coupleId = couple?.id,
partnerLeftEvent = false, partnerLeftEvent = false,
needsRecovery = needsRecovery, needsRecovery = needsRecovery,
needsEncryptionUpgrade = needsEncryptionUpgrade needsEncryptionUpgrade = needsEncryptionUpgrade
@ -217,6 +222,14 @@ class HomeViewModel @Inject constructor(
loadHome() 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() { private fun observeAnswers() {
viewModelScope.launch { viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers -> localAnswerRepository.observeAnswers().collect { answers ->