916 lines
40 KiB
Kotlin
916 lines
40 KiB
Kotlin
package app.closer.ui.home
|
||
|
||
import android.util.Log
|
||
import androidx.lifecycle.ViewModel
|
||
import androidx.lifecycle.viewModelScope
|
||
import app.closer.core.navigation.AppRoute
|
||
import app.closer.crypto.CoupleEncryptionManager
|
||
import app.closer.crypto.EncryptionStatus
|
||
import app.closer.crypto.SealedRevealManager
|
||
import app.closer.domain.model.GameType
|
||
import app.closer.data.remote.FirestoreAnswerDataSource
|
||
import app.closer.data.remote.FirestoreCapsuleDataSource
|
||
import app.closer.data.remote.FirestoreChallengeDataSource
|
||
import app.closer.domain.model.DatePlanStatus
|
||
import app.closer.domain.model.LocalAnswer
|
||
import app.closer.domain.model.Question
|
||
import app.closer.domain.model.QuestionCategory
|
||
import app.closer.domain.repository.AuthRepository
|
||
import app.closer.domain.repository.CoupleRepository
|
||
import app.closer.domain.repository.DatePlanRepository
|
||
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 com.google.firebase.functions.FirebaseFunctionsException
|
||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||
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
|
||
import kotlinx.coroutines.tasks.await
|
||
import kotlinx.coroutines.flow.MutableStateFlow
|
||
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,
|
||
val questionCount: Int
|
||
)
|
||
|
||
data class HomeAnswerStats(
|
||
val total: Int = 0,
|
||
val revealed: Int = 0,
|
||
val private: Int = 0,
|
||
val latest: LocalAnswer? = null,
|
||
val answeredQuestionIds: Set<String> = emptySet()
|
||
)
|
||
|
||
enum class HomeActionTarget {
|
||
InvitePartner,
|
||
DailyQuestion,
|
||
AnswerHistory,
|
||
QuestionPacks,
|
||
Settings,
|
||
AnswerReveal,
|
||
Game,
|
||
Challenge,
|
||
DatePlan,
|
||
MemoryCapsule,
|
||
DateMemories
|
||
}
|
||
|
||
enum class HomeActionTone {
|
||
Invite,
|
||
Daily,
|
||
Reflection,
|
||
Ritual,
|
||
Starter,
|
||
Pack,
|
||
Utility,
|
||
Pending
|
||
}
|
||
|
||
data class HomeAction(
|
||
val eyebrow: String,
|
||
val title: String,
|
||
val body: String,
|
||
val cta: String,
|
||
val target: HomeActionTarget,
|
||
val tone: HomeActionTone,
|
||
val metric: String? = null,
|
||
val categoryId: String? = null,
|
||
// For the "your partner is waiting to play" CTA: the specific game route to resume
|
||
// (so "Play now" jumps into the actual waiting game, not the generic Play hub). B-002.
|
||
val gameRoute: String? = null
|
||
)
|
||
|
||
data class PendingActionCard(
|
||
val title: String,
|
||
val subtitle: String?,
|
||
val priority: Int,
|
||
val target: HomeActionTarget
|
||
)
|
||
|
||
/**
|
||
* The entry route that resumes an in-progress game of [gameType]. Each game screen
|
||
* detects the couple's active session on open and joins it, so navigating here lets the
|
||
* Home "Play now" CTA drop the user straight back into the waiting game (B-002).
|
||
*/
|
||
private fun gameRouteFor(gameType: String?): String? = when (gameType) {
|
||
GameType.WHEEL -> AppRoute.SPIN_WHEEL_RANDOM
|
||
GameType.THIS_OR_THAT -> AppRoute.THIS_OR_THAT
|
||
GameType.HOW_WELL -> AppRoute.HOW_WELL
|
||
GameType.DESIRE_SYNC -> AppRoute.DESIRE_SYNC
|
||
else -> null
|
||
}
|
||
|
||
enum class DailyQuestionState {
|
||
UNANSWERED,
|
||
USER_ANSWERED_PARTNER_PENDING,
|
||
PARTNER_ANSWERED_USER_PENDING,
|
||
BOTH_ANSWERED,
|
||
REVEALED
|
||
}
|
||
|
||
data class HomeUiState(
|
||
val isLoading: Boolean = true,
|
||
val error: String? = null,
|
||
val dailyQuestion: Question? = null,
|
||
val categories: List<HomeCategorySummary> = emptyList(),
|
||
val answerStats: HomeAnswerStats = HomeAnswerStats(),
|
||
val partnerName: String? = null,
|
||
val partnerPhotoUrl: String? = null,
|
||
val streakCount: Int = 0,
|
||
/** When the couple was created (millis), for the partner-sheet "together since" glance. 0 = unknown. */
|
||
val togetherSince: Long = 0L,
|
||
val isPaired: Boolean = false,
|
||
val primaryAction: HomeAction? = null,
|
||
val secondaryActions: List<HomeAction> = emptyList(),
|
||
val partnerLeftEvent: Boolean = false,
|
||
val needsRecovery: Boolean = false,
|
||
val coupleId: String? = null,
|
||
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
|
||
val hasPartnerAnsweredToday: Boolean = false,
|
||
val partnerAnsweredQuestionId: String? = null,
|
||
val hasRevealedToday: Boolean = false,
|
||
val pendingActions: List<PendingActionCard> = emptyList(),
|
||
// Retention signals — populated in loadHome() and observeAnswers()
|
||
val hasWaitingGame: Boolean = false,
|
||
// The route of the active game waiting for this user, so the Home "Play now" CTA
|
||
// resumes that specific game instead of dumping on the generic Play hub (B-002).
|
||
val waitingGameRoute: String? = null,
|
||
// The waiting game's type (e.g. "wheel"), so the Home card can name the game ("… in Spin the Wheel").
|
||
val waitingGameType: String? = null,
|
||
val hasActiveChallenge: Boolean = false,
|
||
val hasUpcomingDatePlan: Boolean = false,
|
||
val hasUnlockedCapsule: Boolean = false,
|
||
// A completed date this user hasn't reflected on yet (drives the Home "reflect on your date" nudge).
|
||
val hasPendingDateReflection: Boolean = false,
|
||
val weeklyRecapReady: Boolean = false,
|
||
val reminderSentEvent: Boolean = false,
|
||
/** "Thinking of you" nudge: in-flight guard + one-shot snackbar message (success or friendly error). */
|
||
val isSendingNudge: Boolean = false,
|
||
val nudgeResult: String? = null,
|
||
// Outcome check-in state
|
||
val outcomeSubmitSuccess: Boolean = false,
|
||
val outcomeError: String? = null,
|
||
val showOutcomeBaselineDialog: Boolean = false,
|
||
val showOutcomeFollowUpDialog: Boolean = false,
|
||
val outcomeFollowUpDay: OutcomeDay? = null,
|
||
/** Non-null when a streak tier was just reached and should be celebrated. */
|
||
val streakMilestone: Int? = null,
|
||
val unreadActivityCount: Int = 0
|
||
)
|
||
|
||
@HiltViewModel
|
||
class HomeViewModel @Inject constructor(
|
||
private val questionRepository: QuestionRepository,
|
||
private val localAnswerRepository: LocalAnswerRepository,
|
||
private val authRepository: AuthRepository,
|
||
private val coupleRepository: CoupleRepository,
|
||
private val userRepository: UserRepository,
|
||
private val encryptionManager: CoupleEncryptionManager,
|
||
private val db: FirebaseFirestore,
|
||
private val functions: FirebaseFunctions,
|
||
private val questionSessionRepository: QuestionSessionRepository,
|
||
private val challengeDataSource: FirestoreChallengeDataSource,
|
||
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
||
private val datePlanRepository: DatePlanRepository,
|
||
private val sealedRevealManager: SealedRevealManager,
|
||
private val outcomeRepository: OutcomeRepository,
|
||
private val settingsRepository: SettingsRepository,
|
||
private val activityDataSource: app.closer.data.remote.FirestoreActivityDataSource,
|
||
private val dailyQuestionResolver: app.closer.domain.usecase.DailyQuestionResolver,
|
||
private val dateMemoryDataSource: app.closer.data.remote.FirestoreDateMemoryDataSource,
|
||
private val dateReflectionDataSource: app.closer.data.remote.FirestoreDateReflectionDataSource
|
||
) : ViewModel() {
|
||
|
||
private val _uiState = MutableStateFlow(HomeUiState())
|
||
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
|
||
|
||
private var coupleStateListener: com.google.firebase.firestore.ListenerRegistration? = null
|
||
private var partnerAnswerListener: com.google.firebase.firestore.ListenerRegistration? = null
|
||
|
||
init {
|
||
loadHome()
|
||
observeAnswers()
|
||
observeCoupleState()
|
||
observeUnreadActivity()
|
||
}
|
||
|
||
private fun observeUnreadActivity() {
|
||
val uid = authRepository.currentUserId ?: return
|
||
viewModelScope.launch {
|
||
activityDataSource.observeUnreadCount(uid).collect { count ->
|
||
_uiState.update { it.copy(unreadActivityCount = count) }
|
||
}
|
||
}
|
||
}
|
||
|
||
override fun onCleared() {
|
||
super.onCleared()
|
||
coupleStateListener?.remove()
|
||
coupleStateListener = null
|
||
partnerAnswerListener?.remove()
|
||
partnerAnswerListener = null
|
||
}
|
||
|
||
fun loadHome() {
|
||
viewModelScope.launch {
|
||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||
try {
|
||
// Resolve via the shared resolver so Home shows the SAME question as the
|
||
// daily-question screen (assignment-backed), keeping answered/waiting state in sync.
|
||
val dailyQuestion = dailyQuestionResolver.resolve().question
|
||
val categories = questionRepository.getCategories()
|
||
.take(6)
|
||
.map { category ->
|
||
HomeCategorySummary(
|
||
category = category,
|
||
questionCount = questionRepository.getQuestionCountByCategory(category.id)
|
||
)
|
||
}
|
||
val uid = authRepository.currentUserId
|
||
uid?.let { launch { runCatching { sealedRevealManager.ensurePublicKeyPublished(it) } } }
|
||
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
|
||
val partnerUser = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId ->
|
||
runCatching { userRepository.getUser(partnerId) }
|
||
.onFailure { Log.w(TAG, "Could not load partner profile", it) }
|
||
.getOrNull()
|
||
}
|
||
val partnerName = partnerUser?.displayName
|
||
val partnerPhotoUrl = partnerUser?.photoUrl
|
||
val encryptionStatus = couple?.let(encryptionManager::checkStatus)
|
||
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY
|
||
|
||
// Heal profile-metadata encryption once the couple key is available (idempotent + targeted;
|
||
// covers pre-pairing-plaintext + legacy users). Skipped when the key isn't present yet.
|
||
if (uid != null && encryptionStatus != null && !needsRecovery) {
|
||
launch {
|
||
runCatching { userRepository.migrateProfileFields(uid) }
|
||
.onFailure { Log.w(TAG, "profile encryption migration failed", it) }
|
||
}
|
||
}
|
||
|
||
// Outcome check-in due-state calculation
|
||
val appSettings = settingsRepository.settings.first()
|
||
val outcomeBaselineShownAt = appSettings.outcomeBaselineShownAt
|
||
|
||
// Streak milestone: celebrate each tier (7/30/100/365) exactly once.
|
||
val streak = couple?.streakCount ?: 0
|
||
val crossedMilestone = STREAK_MILESTONES
|
||
.lastOrNull { streak >= it && appSettings.lastCelebratedStreakMilestone < it }
|
||
if (crossedMilestone != null) {
|
||
runCatching { settingsRepository.setLastCelebratedStreakMilestone(crossedMilestone) }
|
||
}
|
||
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 waitingGameRoute: String? = null
|
||
var waitingGameType: String? = null
|
||
var hasActiveChallenge = false
|
||
var hasUpcomingDatePlan = false
|
||
var hasUnlockedCapsule = false
|
||
var hasPendingDateReflection = false
|
||
val coupleId = couple?.id
|
||
if (couple != null && coupleId != null && uid != null) {
|
||
coroutineScope {
|
||
val gameJob = async {
|
||
runCatching {
|
||
val session = questionSessionRepository.getActiveSessionForCouple(coupleId)
|
||
?.takeIf { uid !in it.completedByUsers }
|
||
session to gameRouteFor(session?.gameType)
|
||
}.getOrDefault(null to null)
|
||
}
|
||
val challengeJob = async {
|
||
runCatching {
|
||
challengeDataSource.getActiveChallengeId(coupleId) != null
|
||
}.getOrDefault(false)
|
||
}
|
||
val dateJob = async {
|
||
runCatching {
|
||
val now = System.currentTimeMillis()
|
||
val sevenDaysMs = 7L * 24 * 60 * 60 * 1000
|
||
datePlanRepository.getPlansByStatus(
|
||
coupleId, DatePlanStatus.PLANNED.toFirestoreValue()
|
||
).any { it.scheduledDate > now && it.scheduledDate - now <= sevenDaysMs }
|
||
}.getOrDefault(false)
|
||
}
|
||
val capsuleJob = async {
|
||
runCatching {
|
||
val now = System.currentTimeMillis()
|
||
capsuleDataSource.getCapsules(coupleId)
|
||
.any { it.status == "sealed" && it.unlockAt in 1L..now }
|
||
}.getOrDefault(false)
|
||
}
|
||
// Pending date reflection: the most recent completed date this user hasn't
|
||
// reflected on yet. The nudge chases the latest date; older un-reflected dates
|
||
// remain reachable from the Replay timeline.
|
||
val reflectionJob = async {
|
||
runCatching {
|
||
val latest = dateMemoryDataSource.getHistoryOnce(coupleId).firstOrNull()
|
||
latest != null &&
|
||
!dateReflectionDataSource.hasReflected(coupleId, latest.id, uid)
|
||
}.getOrDefault(false)
|
||
}
|
||
val (waitingSession, waitingRoute) = gameJob.await()
|
||
hasWaitingGame = waitingSession != null
|
||
waitingGameRoute = waitingRoute
|
||
waitingGameType = waitingSession?.gameType
|
||
hasActiveChallenge = challengeJob.await()
|
||
hasUpcomingDatePlan = dateJob.await()
|
||
hasUnlockedCapsule = capsuleJob.await()
|
||
hasPendingDateReflection = reflectionJob.await()
|
||
}
|
||
}
|
||
|
||
_uiState.update { current ->
|
||
current.copy(
|
||
isLoading = false,
|
||
dailyQuestion = dailyQuestion,
|
||
categories = categories,
|
||
partnerName = partnerName,
|
||
partnerPhotoUrl = partnerPhotoUrl,
|
||
streakCount = couple?.streakCount ?: 0,
|
||
togetherSince = couple?.createdAt ?: 0L,
|
||
isPaired = couple != null,
|
||
coupleId = coupleId,
|
||
partnerLeftEvent = false,
|
||
needsRecovery = needsRecovery,
|
||
hasWaitingGame = hasWaitingGame,
|
||
waitingGameRoute = waitingGameRoute,
|
||
waitingGameType = waitingGameType,
|
||
hasActiveChallenge = hasActiveChallenge,
|
||
hasUpcomingDatePlan = hasUpcomingDatePlan,
|
||
hasUnlockedCapsule = hasUnlockedCapsule,
|
||
hasPendingDateReflection = hasPendingDateReflection,
|
||
showOutcomeBaselineDialog = showBaselineDialog,
|
||
showOutcomeFollowUpDialog = followUpDay != null,
|
||
outcomeFollowUpDay = followUpDay,
|
||
streakMilestone = crossedMilestone ?: current.streakMilestone
|
||
).refreshDailyQuestionState().withHomeActions()
|
||
}
|
||
observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id)
|
||
} catch (e: Exception) {
|
||
_uiState.update {
|
||
it.copy(
|
||
isLoading = false,
|
||
error = e.message ?: "Couldn't load your space right now."
|
||
).withHomeActions()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun observeCoupleState() {
|
||
val uid = authRepository.currentUserId ?: return
|
||
coupleStateListener?.remove()
|
||
coupleStateListener = db.collection("users").document(uid)
|
||
.addSnapshotListener(com.google.firebase.firestore.MetadataChanges.INCLUDE) { snapshot, error ->
|
||
if (error != null || snapshot == null || !snapshot.exists()) {
|
||
return@addSnapshotListener
|
||
}
|
||
val newCoupleId = snapshot.getString("coupleId")
|
||
val oldIsPaired = _uiState.value.isPaired
|
||
val oldPartnerName = _uiState.value.partnerName
|
||
val newIsPaired = !newCoupleId.isNullOrBlank()
|
||
|
||
if (newIsPaired != oldIsPaired) {
|
||
// Capture partner name before reload so the banner can reference it.
|
||
val showPartnerLeftBanner = oldIsPaired && !newIsPaired
|
||
if (showPartnerLeftBanner) {
|
||
_uiState.update {
|
||
it.copy(
|
||
isPaired = false,
|
||
partnerName = null,
|
||
partnerLeftEvent = true
|
||
).withHomeActions()
|
||
}
|
||
}
|
||
loadHome()
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Consumes the partner-left banner event. Call from the UI after showing it.
|
||
*/
|
||
fun consumePartnerLeftEvent() {
|
||
_uiState.update { it.copy(partnerLeftEvent = false) }
|
||
}
|
||
|
||
/** Called after the recovery flow completes so the banner goes away. */
|
||
fun onRecoveryCompleted() {
|
||
_uiState.update { it.copy(needsRecovery = false) }
|
||
loadHome()
|
||
}
|
||
|
||
private fun dueFollowUpDay(createdAt: Long, outcomes: List<Outcome>): 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.
|
||
*/
|
||
fun sendGentleReminder() {
|
||
// Only meaningful when we have answered but partner hasn't.
|
||
val state = _uiState.value
|
||
if (state.dailyQuestionState != DailyQuestionState.USER_ANSWERED_PARTNER_PENDING) return
|
||
viewModelScope.launch {
|
||
runCatching {
|
||
functions.getHttpsCallable("sendGentleReminderCallable").call().await()
|
||
}.onSuccess {
|
||
_uiState.update { it.copy(reminderSentEvent = true) }
|
||
}.onFailure { e ->
|
||
Log.w(TAG, "Gentle reminder failed", e)
|
||
}
|
||
}
|
||
}
|
||
|
||
fun consumeReminderSentEvent() = _uiState.update { it.copy(reminderSentEvent = false) }
|
||
|
||
/**
|
||
* Send a "thinking of you 💜" nudge to the partner via the callable (rate-limited + quiet-hours-aware
|
||
* server-side). Fails gracefully: maps the error to a friendly one-shot message, never crashes, and
|
||
* the rest of the sheet works regardless (and before the function is deployed).
|
||
*/
|
||
fun sendThinkingOfYou() {
|
||
val state = _uiState.value
|
||
if (!state.isPaired || state.isSendingNudge) return
|
||
_uiState.update { it.copy(isSendingNudge = true) }
|
||
viewModelScope.launch {
|
||
val message = runCatching {
|
||
functions.getHttpsCallable("sendThinkingOfYouCallable").call().await()
|
||
}.fold(
|
||
onSuccess = { "Sent 💜" },
|
||
onFailure = { e ->
|
||
Log.w(TAG, "Thinking of you failed", e)
|
||
if ((e as? FirebaseFunctionsException)?.code ==
|
||
FirebaseFunctionsException.Code.RESOURCE_EXHAUSTED
|
||
) {
|
||
"You've sent a few already — give it a moment 💜"
|
||
} else {
|
||
"Couldn't send right now. Try again."
|
||
}
|
||
}
|
||
)
|
||
_uiState.update { it.copy(isSendingNudge = false, nudgeResult = message) }
|
||
}
|
||
}
|
||
|
||
fun consumeNudgeResult() = _uiState.update { it.copy(nudgeResult = null) }
|
||
|
||
fun consumeStreakMilestone() = _uiState.update { it.copy(streakMilestone = null) }
|
||
|
||
private fun observeAnswers() {
|
||
viewModelScope.launch {
|
||
localAnswerRepository.observeAnswers().collect { answers ->
|
||
val sorted = answers.sortedByDescending { it.updatedAt }
|
||
val now = System.currentTimeMillis()
|
||
val sevenDaysMs = 7L * 24 * 60 * 60 * 1000
|
||
val isMonday = LocalDate.now().dayOfWeek == DayOfWeek.MONDAY
|
||
val weeklyRecapReady = isMonday && answers.any { now - it.updatedAt <= sevenDaysMs }
|
||
_uiState.update {
|
||
it.copy(
|
||
answerStats = HomeAnswerStats(
|
||
total = answers.size,
|
||
revealed = answers.count { answer -> answer.isRevealed },
|
||
private = answers.count { answer -> !answer.isRevealed },
|
||
latest = sorted.firstOrNull(),
|
||
answeredQuestionIds = answers.map { answer -> answer.questionId }.toSet()
|
||
),
|
||
weeklyRecapReady = weeklyRecapReady
|
||
).refreshDailyQuestionState().withHomeActions()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun observePartnerAnswer(
|
||
coupleId: String?,
|
||
coupleUserIds: List<String>?,
|
||
dailyQuestionId: String?
|
||
) {
|
||
partnerAnswerListener?.remove()
|
||
partnerAnswerListener = null
|
||
val cId = coupleId ?: return
|
||
val qId = dailyQuestionId ?: return
|
||
val uid = authRepository.currentUserId ?: return
|
||
val partnerId = coupleUserIds?.firstOrNull { it != uid } ?: return
|
||
|
||
val today = FirestoreAnswerDataSource.todayLocalDateString()
|
||
partnerAnswerListener = db.collection("couples")
|
||
.document(cId)
|
||
.collection("daily_question")
|
||
.document(today)
|
||
.collection("answers")
|
||
.document(partnerId)
|
||
.addSnapshotListener(com.google.firebase.firestore.MetadataChanges.INCLUDE) { snapshot, error ->
|
||
if (error != null) {
|
||
Log.w(TAG, "Partner answer listener failed", error)
|
||
return@addSnapshotListener
|
||
}
|
||
val hasPartnerAnswer = snapshot?.exists() == true
|
||
_uiState.update {
|
||
it.copy(
|
||
hasPartnerAnsweredToday = hasPartnerAnswer,
|
||
partnerAnsweredQuestionId = if (hasPartnerAnswer) qId else null
|
||
).refreshDailyQuestionState().withHomeActions()
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun HomeUiState.refreshDailyQuestionState(): HomeUiState {
|
||
val questionId = dailyQuestion?.id
|
||
val userAnswered = questionId != null && questionId in answerStats.answeredQuestionIds
|
||
val userRevealed = questionId != null && answerStats.latest?.let { latest ->
|
||
latest.questionId == questionId && latest.isRevealed
|
||
} == true
|
||
|
||
val state = when {
|
||
questionId == null -> DailyQuestionState.UNANSWERED
|
||
userRevealed -> DailyQuestionState.REVEALED
|
||
userAnswered && hasPartnerAnsweredToday -> DailyQuestionState.BOTH_ANSWERED
|
||
userAnswered -> DailyQuestionState.USER_ANSWERED_PARTNER_PENDING
|
||
hasPartnerAnsweredToday -> DailyQuestionState.PARTNER_ANSWERED_USER_PENDING
|
||
else -> DailyQuestionState.UNANSWERED
|
||
}
|
||
return copy(
|
||
dailyQuestionState = state,
|
||
hasRevealedToday = userRevealed
|
||
)
|
||
}
|
||
|
||
private fun HomeUiState.withHomeActions(): HomeUiState {
|
||
if (isLoading || error != null) {
|
||
return copy(primaryAction = null, secondaryActions = emptyList(), pendingActions = emptyList())
|
||
}
|
||
|
||
val engineInput = PriorityInput(
|
||
needsCriticalAction = needsRecovery,
|
||
isPaired = isPaired,
|
||
needsEncryptionUnlock = needsRecovery,
|
||
revealReady = dailyQuestionState == DailyQuestionState.BOTH_ANSWERED,
|
||
partnerAnsweredUserPending = dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING,
|
||
gameWaiting = hasWaitingGame(),
|
||
challengeWaiting = hasIncompleteChallenge(),
|
||
dailyQuestionUnanswered = dailyQuestionState == DailyQuestionState.UNANSWERED && dailyQuestion != null,
|
||
dailyQuestionAwaitingPartner = dailyQuestionState == DailyQuestionState.USER_ANSWERED_PARTNER_PENDING,
|
||
dailyQuestionRevealed = dailyQuestionState == DailyQuestionState.REVEALED && dailyQuestion != null,
|
||
weeklyRecapReady = weeklyRecapReady,
|
||
capsuleUnlocked = hasUnlockedCapsule(),
|
||
dateReminder = hasUpcomingDate(),
|
||
dateReflectionPending = hasPendingDateReflection,
|
||
suggestedPackAvailable = categories.isNotEmpty(),
|
||
exploreGamesAvailable = categories.isNotEmpty()
|
||
)
|
||
val priorityOutput = HomePriorityEngine.compute(engineInput)
|
||
|
||
val primary = priorityOutput.primary?.let { toHomeAction(it.priority) }
|
||
val secondary = priorityOutput.secondary.mapNotNull { toHomeAction(it.priority) }
|
||
// The primary action already gets the prominent hero card; drop it from the "Waiting for
|
||
// you" list so the same item isn't surfaced twice (C-HOME-001).
|
||
val pending = buildPendingActions().filterNot { pending ->
|
||
pending.target == primary?.target ||
|
||
(primary?.target == HomeActionTarget.DailyQuestion &&
|
||
(pending.target == HomeActionTarget.AnswerReveal ||
|
||
pending.target == HomeActionTarget.DailyQuestion))
|
||
}
|
||
|
||
return copy(
|
||
primaryAction = primary,
|
||
secondaryActions = secondary.take(3),
|
||
pendingActions = pending.take(3)
|
||
)
|
||
}
|
||
|
||
private fun HomeUiState.toHomeAction(priority: Priority): HomeAction? = when (priority) {
|
||
Priority.CRITICAL_ACTION ->
|
||
if (needsRecovery) HomeAction(
|
||
eyebrow = "Account recovery",
|
||
title = "Secure your answers before continuing.",
|
||
body = "A privacy action needs your attention. Complete recovery to keep your shared space safe.",
|
||
cta = "Start recovery",
|
||
target = HomeActionTarget.Settings,
|
||
tone = HomeActionTone.Utility
|
||
) else null
|
||
|
||
Priority.PAIRING_NEEDED -> HomeAction(
|
||
eyebrow = "1 of 2 connected",
|
||
title = "A private space for two",
|
||
body = "Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.",
|
||
cta = "Invite partner",
|
||
target = HomeActionTarget.InvitePartner,
|
||
tone = HomeActionTone.Invite
|
||
)
|
||
|
||
Priority.ENCRYPTION_UNLOCK_NEEDED -> HomeAction(
|
||
eyebrow = "Encryption unlock",
|
||
title = "Unlock your shared answers.",
|
||
body = "Your couple's encryption needs to be restored. Complete recovery to keep accessing your answers.",
|
||
cta = "Recover keys",
|
||
target = HomeActionTarget.Settings,
|
||
tone = HomeActionTone.Utility
|
||
)
|
||
|
||
Priority.REVEAL_READY -> buildDailyQuestionAction(
|
||
title = "Reveal is ready.",
|
||
body = "Both of you answered. Open it together when you are both in the right headspace.",
|
||
cta = "Reveal together"
|
||
)
|
||
|
||
Priority.PARTNER_ANSWERED_USER_PENDING -> buildDailyQuestionAction(
|
||
title = "Your partner answered. Your turn.",
|
||
body = "Answer to unlock the reveal. Your response stays private until you are ready.",
|
||
cta = "Answer to unlock reveal"
|
||
)
|
||
|
||
// NOTE: GAME_WAITING fires whenever there's an active session this user hasn't finished
|
||
// (for async games like This or That, completedByUsers stays empty until BOTH finish, so we
|
||
// cannot assume the partner has played their part here). Keep this copy accurate for every
|
||
// state — the real-time "X played their part, your turn" nudge is delivered separately by the
|
||
// push-driven YOUR_TURN GamePromptBanner, which knows the partner actually finished first.
|
||
Priority.GAME_WAITING -> HomeAction(
|
||
eyebrow = "Game in progress",
|
||
title = "Pick up your game.",
|
||
body = partnerName?.let {
|
||
"Jump back in to finish your picks and see how you and $it line up."
|
||
} ?: "Jump back in to finish your picks and see how you two line up.",
|
||
cta = "Play now",
|
||
target = HomeActionTarget.Game,
|
||
tone = HomeActionTone.Ritual,
|
||
gameRoute = waitingGameRoute
|
||
)
|
||
|
||
Priority.CHALLENGE_WAITING -> HomeAction(
|
||
eyebrow = "Connection challenge",
|
||
title = "Today’s challenge step is ready.",
|
||
body = "Open one small shared action for tonight. It is meant to feel doable, not like homework.",
|
||
cta = "Open challenge",
|
||
target = HomeActionTarget.Challenge,
|
||
tone = HomeActionTone.Ritual
|
||
)
|
||
|
||
Priority.DAILY_QUESTION_UNANSWERED -> buildDailyQuestionAction(
|
||
title = dailyQuestion?.text ?: "Tonight's question is ready.",
|
||
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
|
||
cta = "Answer privately"
|
||
)
|
||
|
||
// You answered; the reveal waits on your partner. The hero card (PrimaryHomeActionCard) overrides
|
||
// this title/body and routes the CTA to the gentle-reminder send; this copy/CTA label is what shows
|
||
// when it renders as a smaller secondary card (a game/challenge is the hero).
|
||
Priority.DAILY_QUESTION_AWAITING_PARTNER -> buildDailyQuestionAction(
|
||
title = "You showed up tonight.",
|
||
body = partnerName?.let { "Your answer stays private until $it answers too — no pressure." }
|
||
?: "Your answer stays private until your partner answers too — no pressure.",
|
||
cta = "Send a gentle nudge"
|
||
)
|
||
|
||
// You already revealed today — a low-priority closure card that links to the discussion thread.
|
||
Priority.DAILY_QUESTION_REVEALED -> buildDailyQuestionAction(
|
||
title = "You opened a conversation tonight.",
|
||
body = "Keep it going whenever you're both ready.",
|
||
cta = "Keep the conversation going"
|
||
)
|
||
|
||
Priority.WEEKLY_RECAP_READY -> HomeAction(
|
||
eyebrow = "Your week together",
|
||
title = "Look back at what you built this week.",
|
||
body = "Reveals, answers, and small rituals are summarized for just the two of you.",
|
||
cta = "See recap",
|
||
target = HomeActionTarget.AnswerHistory,
|
||
tone = HomeActionTone.Reflection
|
||
)
|
||
|
||
Priority.CAPSULE_UNLOCKED -> HomeAction(
|
||
eyebrow = "Memory capsule",
|
||
title = "A saved memory is ready to open.",
|
||
body = "One of your time capsules unlocked. Open it together and remember why you saved it.",
|
||
cta = "Open capsule",
|
||
target = HomeActionTarget.MemoryCapsule,
|
||
tone = HomeActionTone.Reflection
|
||
)
|
||
|
||
Priority.DATE_REMINDER -> HomeAction(
|
||
eyebrow = "Date coming up",
|
||
title = "A planned moment is almost here.",
|
||
body = "You saved a date idea together. Check the details before the night arrives.",
|
||
cta = "View date",
|
||
target = HomeActionTarget.DatePlan,
|
||
tone = HomeActionTone.Ritual
|
||
)
|
||
|
||
Priority.DATE_REFLECTION_PENDING -> HomeAction(
|
||
eyebrow = "Date replay",
|
||
title = partnerName?.let { "Reflect on your date with $it 💭" }
|
||
?: "Reflect on your date 💭",
|
||
body = "Capture what the night meant to you. You'll reveal your reflections together when you're both ready.",
|
||
cta = "Add your reflection",
|
||
target = HomeActionTarget.DateMemories,
|
||
tone = HomeActionTone.Reflection
|
||
)
|
||
|
||
Priority.SUGGESTED_PACK -> categories.firstOrNull()?.let { category ->
|
||
HomeAction(
|
||
eyebrow = "Suggested pack",
|
||
title = category.category.displayName.ifBlank { "Question pack" },
|
||
body = "${category.questionCount} questions for when you want a different doorway into the conversation.",
|
||
cta = "Open pack",
|
||
target = HomeActionTarget.QuestionPacks,
|
||
tone = HomeActionTone.Pack,
|
||
categoryId = category.category.id
|
||
)
|
||
}
|
||
|
||
Priority.EXPLORE_GAMES -> HomeAction(
|
||
eyebrow = "Explore",
|
||
title = "Try a game together.",
|
||
body = "Playful ways to connect when you both want something light.",
|
||
cta = "Browse games",
|
||
target = HomeActionTarget.QuestionPacks,
|
||
tone = HomeActionTone.Starter
|
||
)
|
||
}
|
||
|
||
private fun HomeUiState.buildDailyQuestionAction(
|
||
title: String,
|
||
body: String,
|
||
cta: String
|
||
): HomeAction = HomeAction(
|
||
eyebrow = "Your daily question",
|
||
title = title,
|
||
body = body,
|
||
cta = cta,
|
||
target = HomeActionTarget.DailyQuestion,
|
||
tone = HomeActionTone.Daily,
|
||
metric = dailyQuestion?.category?.takeIf { it.isNotBlank() }?.toHomeLabel()
|
||
)
|
||
|
||
private fun HomeUiState.buildPendingActions(): List<PendingActionCard> {
|
||
if (!isPaired) return emptyList()
|
||
|
||
val actions = mutableListOf<PendingActionCard>()
|
||
|
||
if (dailyQuestionState == DailyQuestionState.BOTH_ANSWERED) {
|
||
actions += PendingActionCard(
|
||
title = "Reveal is ready",
|
||
subtitle = "Both of you answered tonight. Open it together.",
|
||
priority = 1,
|
||
target = HomeActionTarget.AnswerReveal
|
||
)
|
||
}
|
||
|
||
if (dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING) {
|
||
actions += PendingActionCard(
|
||
title = "Your partner answered",
|
||
subtitle = "Answer tonight’s question to unlock the reveal.",
|
||
priority = 2,
|
||
target = HomeActionTarget.DailyQuestion
|
||
)
|
||
}
|
||
|
||
if (hasWaitingGame()) {
|
||
actions += PendingActionCard(
|
||
title = "Game waiting",
|
||
subtitle = "Your turn to play a game together.",
|
||
priority = 3,
|
||
target = HomeActionTarget.Game
|
||
)
|
||
}
|
||
|
||
if (hasIncompleteChallenge()) {
|
||
actions += PendingActionCard(
|
||
title = "Challenge waiting",
|
||
subtitle = "Today’s small step is ready for both of you.",
|
||
priority = 4,
|
||
target = HomeActionTarget.Challenge
|
||
)
|
||
}
|
||
|
||
if (hasUpcomingDate()) {
|
||
actions += PendingActionCard(
|
||
title = "Date coming up",
|
||
subtitle = "A planned moment is almost here.",
|
||
priority = 5,
|
||
target = HomeActionTarget.DatePlan
|
||
)
|
||
}
|
||
|
||
if (hasPendingDateReflection) {
|
||
actions += PendingActionCard(
|
||
title = "Reflect on your date",
|
||
subtitle = "Capture the night, then reveal together.",
|
||
priority = 6,
|
||
target = HomeActionTarget.DateMemories
|
||
)
|
||
}
|
||
|
||
if (hasUnlockedCapsule()) {
|
||
actions += PendingActionCard(
|
||
title = "Capsule unlocked",
|
||
subtitle = "A saved memory is ready to open together.",
|
||
priority = 7,
|
||
target = HomeActionTarget.MemoryCapsule
|
||
)
|
||
}
|
||
|
||
return actions.sortedBy { it.priority }
|
||
}
|
||
|
||
private fun HomeUiState.hasWaitingGame(): Boolean = hasWaitingGame
|
||
|
||
private fun HomeUiState.hasIncompleteChallenge(): Boolean = hasActiveChallenge
|
||
|
||
private fun HomeUiState.hasUpcomingDate(): Boolean = hasUpcomingDatePlan
|
||
|
||
private fun HomeUiState.hasUnlockedCapsule(): Boolean = hasUnlockedCapsule
|
||
|
||
private fun String.toHomeLabel(): String =
|
||
split("_", "-")
|
||
.filter { part -> part.isNotBlank() }
|
||
.joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } }
|
||
|
||
companion object {
|
||
private const val TAG = "HomeViewModel"
|
||
private val STREAK_MILESTONES = listOf(7, 30, 100, 365)
|
||
}
|
||
}
|