From 0b619ee7ba4348ec1f32eb93270bf43c1c48f0e1 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 19 Jun 2026 22:23:24 -0500 Subject: [PATCH] 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() --- .../java/app/closer/ui/home/HomeScreen.kt | 122 +++++++++++++++--- .../java/app/closer/ui/home/HomeViewModel.kt | 13 ++ 2 files changed, 118 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 1128a018..7489c30b 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -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 = {}, diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 8fc1c601..4f7ad11a 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -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 ->