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
2649cc193b
commit
b570e32217
|
|
@ -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 = {},
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue