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:
parent
aff1150295
commit
0b619ee7ba
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
Loading…
Reference in New Issue