From b70463274fd6bc6da6936ee98d30811918ae5b11 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 21 Jun 2026 00:02:02 -0500 Subject: [PATCH] fix(home): resolve OutcomeDay import for outcome dialogs --- .../java/app/closer/ui/home/HomeScreen.kt | 59 ++++++++++++ .../java/app/closer/ui/home/HomeViewModel.kt | 92 ++++++++++++++++++- .../app/closer/ui/settings/SettingsScreen.kt | 2 +- 3 files changed, 149 insertions(+), 4 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 364290eb..c3614173 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -32,9 +32,12 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TrendingUp import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.LocalFireDepartment +import app.closer.domain.model.OutcomeDay +import app.closer.ui.components.OutcomeCheckInDialog import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -43,7 +46,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -97,6 +102,60 @@ fun HomeScreen( } } + var showBaselineDialog by remember { mutableStateOf(false) } + var showFollowUpDialog by remember { mutableStateOf(false) } + var pendingFollowUpDay by remember { mutableStateOf(null) } + + LaunchedEffect(state.showOutcomeBaselineDialog) { + showBaselineDialog = state.showOutcomeBaselineDialog + } + LaunchedEffect(state.showOutcomeFollowUpDialog, state.outcomeFollowUpDay) { + showFollowUpDialog = state.showOutcomeFollowUpDialog + pendingFollowUpDay = state.outcomeFollowUpDay + } + + if (showBaselineDialog) { + OutcomeCheckInDialog( + title = "Quick check-in", + subtitle = "Before you start, how are you feeling about your relationship right now?", + onDismiss = { + viewModel.markBaselineOutcomeShown() + showBaselineDialog = false + }, + onSubmit = { scores -> + viewModel.submitOutcome(OutcomeDay.BASELINE.key, scores) + showBaselineDialog = false + } + ) + } + + pendingFollowUpDay?.let { day -> + if (showFollowUpDialog) { + OutcomeCheckInDialog( + title = "${day.label} check-in", + subtitle = "How are you feeling now compared to when you started?", + onDismiss = { + viewModel.markFollowUpOutcomeShown(day.key) + showFollowUpDialog = false + pendingFollowUpDay = null + }, + onSubmit = { scores -> + viewModel.submitOutcome(day.key, scores) + showFollowUpDialog = false + pendingFollowUpDay = null + } + ) + } + } + + LaunchedEffect(state.outcomeSubmitSuccess, state.outcomeError) { + state.outcomeError?.let { snackbarHostState.showSnackbar(it); viewModel.consumeOutcomeError() } + if (state.outcomeSubmitSuccess) { + snackbarHostState.showSnackbar("Check-in saved") + viewModel.consumeOutcomeSuccess() + } + } + HomeContent( state = state, snackbarHostState = snackbarHostState, 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 ee21b765..dbf93af5 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -20,6 +20,11 @@ import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.repository.UserRepository +import app.closer.domain.model.Outcome +import app.closer.domain.model.OutcomeDay +import app.closer.domain.model.OutcomeScores +import app.closer.domain.repository.OutcomeRepository +import app.closer.domain.repository.SettingsRepository import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.functions.FirebaseFunctions import dagger.hilt.android.lifecycle.HiltViewModel @@ -27,6 +32,7 @@ import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput import app.closer.ui.home.HomePriorityEngine.Priority import java.time.DayOfWeek import java.time.LocalDate +import java.time.ZoneId import javax.inject.Inject import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -36,6 +42,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first data class HomeCategorySummary( val category: QuestionCategory, @@ -126,7 +133,13 @@ data class HomeUiState( val hasUpcomingDatePlan: Boolean = false, val hasUnlockedCapsule: Boolean = false, val weeklyRecapReady: Boolean = false, - val reminderSentEvent: Boolean = false + val reminderSentEvent: Boolean = false, + // Outcome check-in state + val outcomeSubmitSuccess: Boolean = false, + val outcomeError: String? = null, + val showOutcomeBaselineDialog: Boolean = false, + val showOutcomeFollowUpDialog: Boolean = false, + val outcomeFollowUpDay: OutcomeDay? = null ) @HiltViewModel @@ -143,7 +156,9 @@ class HomeViewModel @Inject constructor( private val challengeDataSource: FirestoreChallengeDataSource, private val capsuleDataSource: FirestoreCapsuleDataSource, private val datePlanRepository: DatePlanRepository, - private val sealedRevealManager: SealedRevealManager + private val sealedRevealManager: SealedRevealManager, + private val outcomeRepository: OutcomeRepository, + private val settingsRepository: SettingsRepository ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) @@ -196,6 +211,17 @@ class HomeViewModel @Inject constructor( else -> false } + // Outcome check-in due-state calculation + val outcomeBaselineShownAt = settingsRepository.settings.first().outcomeBaselineShownAt + val outcomes = couple?.let { + runCatching { outcomeRepository.getOutcomes(it.id) } + .onFailure { Log.w(TAG, "Could not load outcomes", it) } + .getOrDefault(emptyList()) + } ?: emptyList() + val baselineRecorded = outcomes.any { it.dayKey == OutcomeDay.BASELINE.key } + val showBaselineDialog = couple != null && !baselineRecorded && outcomeBaselineShownAt == 0L + val followUpDay = couple?.let { dueFollowUpDay(it.createdAt, outcomes) } + // Retention signal fetches — run in parallel, failures silently default to false. var hasWaitingGame = false var hasActiveChallenge = false @@ -253,7 +279,10 @@ class HomeViewModel @Inject constructor( hasWaitingGame = hasWaitingGame, hasActiveChallenge = hasActiveChallenge, hasUpcomingDatePlan = hasUpcomingDatePlan, - hasUnlockedCapsule = hasUnlockedCapsule + hasUnlockedCapsule = hasUnlockedCapsule, + showOutcomeBaselineDialog = showBaselineDialog, + showOutcomeFollowUpDialog = followUpDay != null, + outcomeFollowUpDay = followUpDay ).refreshDailyQuestionState().withHomeActions() } observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id) @@ -311,6 +340,63 @@ class HomeViewModel @Inject constructor( loadHome() } + private fun dueFollowUpDay(createdAt: Long, outcomes: List): OutcomeDay? { + val paired = java.time.Instant.ofEpochMilli(createdAt).atZone(ZoneId.systemDefault()).toLocalDate() + val today = LocalDate.now() + val ageDays = java.time.temporal.ChronoUnit.DAYS.between(paired, today) + val due = listOf( + OutcomeDay.DAY_30 to 30, + OutcomeDay.DAY_60 to 60, + OutcomeDay.DAY_90 to 90 + ).firstOrNull { (_, days) -> ageDays >= days } ?: return null + return if (outcomes.any { it.dayKey == due.first.key }) null else due.first + } + + fun submitOutcome(dayKey: String, scores: OutcomeScores) { + viewModelScope.launch { + _uiState.update { it.copy(outcomeSubmitSuccess = false, outcomeError = null) } + try { + val uid = authRepository.currentUserId + ?: throw IllegalStateException("Not signed in.") + val couple = coupleRepository.getCoupleForUser(uid) + ?: throw IllegalStateException("Not paired.") + outcomeRepository.submitOutcome(couple.id, dayKey, scores).getOrThrow() + settingsRepository.setOutcomeLastPromptedDay(dayKey) + if (dayKey == OutcomeDay.BASELINE.key) { + settingsRepository.markOutcomeBaselineShown() + } + _uiState.update { + it.copy( + outcomeSubmitSuccess = true, + showOutcomeBaselineDialog = false, + showOutcomeFollowUpDialog = false, + outcomeFollowUpDay = null + ) + } + loadHome() + } catch (e: Exception) { + _uiState.update { it.copy(outcomeError = e.message ?: "Couldn’t save check-in.") } + } + } + } + + fun markBaselineOutcomeShown() { + viewModelScope.launch { + settingsRepository.markOutcomeBaselineShown() + _uiState.update { it.copy(showOutcomeBaselineDialog = false) } + } + } + + fun markFollowUpOutcomeShown(dayKey: String) { + viewModelScope.launch { + settingsRepository.setOutcomeLastPromptedDay(dayKey) + _uiState.update { it.copy(showOutcomeFollowUpDialog = false, outcomeFollowUpDay = null) } + } + } + + fun consumeOutcomeSuccess() = _uiState.update { it.copy(outcomeSubmitSuccess = false) } + fun consumeOutcomeError() = _uiState.update { it.copy(outcomeError = null) } + /** * Sends a gentle reminder to the partner that the daily question is waiting. * Notification wiring is intentionally a no-op until Batch 6. diff --git a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt index 9a4fde93..1cfa94f8 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt @@ -20,10 +20,10 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.TrendingUp import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder -import androidx.compose.material.icons.filled.TrendingUp import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette