From 0c5900006564a9faa66126bf894767e8adbbd583 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 6 Jul 2026 22:18:30 -0500 Subject: [PATCH] refactor(ui): remove raw Firestore listeners from Daily/Partner VMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DailyQuestionViewModel and PartnerHomeViewModel each held a raw db.collection(...).addSnapshotListener on the partner's answer doc (just reading snapshot.exists()). Replace with the existing, proven FirestoreAnswerDataSource.observeAnswerForUser(...) Flow collected in a viewModelScope Job (auto-cancelled; previous run cancelled on restart) — dropping the FirebaseFirestore injection from both. Same behavior (exists → non-null), now through the data layer. Verified live: daily question shows the correct "waiting for your partner" state via the new Flow; compiles + unit suite green. HomeViewModel's two listeners use MetadataChanges.INCLUDE + inline analytics and drive the core reveal — left as a scoped follow-on (needs a metadata-aware data-source Flow + two-device real-time verification). See docs/standardization-review.md. Co-Authored-By: Claude Fable 5 --- .../app/closer/ui/home/PartnerHomeScreen.kt | 25 +++++++--------- .../ui/questions/DailyQuestionViewModel.kt | 29 +++++++------------ 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt b/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt index 8503ac6b..80b67bf1 100644 --- a/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt @@ -59,7 +59,6 @@ import app.closer.ui.components.LoadingState import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerCardColor -import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.functions.FirebaseFunctions import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -67,6 +66,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import app.closer.ui.components.CloserGlyphs @@ -91,14 +91,14 @@ class PartnerHomeViewModel @Inject constructor( private val coupleRepository: CoupleRepository, private val userRepository: UserRepository, private val answerDataSource: FirestoreAnswerDataSource, - private val db: FirebaseFirestore, private val functions: FirebaseFunctions, ) : ViewModel() { private val _uiState = MutableStateFlow(PartnerHomeUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private var partnerAnswerListener: com.google.firebase.firestore.ListenerRegistration? = null + // Cancelled/replaced each time the observer (re)starts; auto-cancelled with viewModelScope. + private var partnerObserverJob: Job? = null init { load() @@ -148,21 +148,16 @@ class PartnerHomeViewModel @Inject constructor( } private fun observePartnerAnswer(coupleId: String, userIds: List, questionId: String?) { - partnerAnswerListener?.remove() + partnerObserverJob?.cancel() val uid = authRepository.currentUserId ?: return val partnerId = userIds.firstOrNull { it != uid } ?: return - val qId = questionId ?: return + if (questionId == null) return val today = FirestoreAnswerDataSource.todayLocalDateString() - partnerAnswerListener = db.collection("couples").document(coupleId) - .collection("daily_question").document(today) - .collection("answers").document(partnerId) - .addSnapshotListener { snapshot, error -> - if (error != null) { - Log.w(TAG, "Partner answer listener error", error) - return@addSnapshotListener - } - _uiState.update { it.copy(hasPartnerAnsweredToday = snapshot?.exists() == true) } + partnerObserverJob = viewModelScope.launch { + answerDataSource.observeAnswerForUser(coupleId, partnerId, today).collect { partnerAnswer -> + _uiState.update { it.copy(hasPartnerAnsweredToday = partnerAnswer != null) } } + } } fun sendReminder() { @@ -185,7 +180,7 @@ class PartnerHomeViewModel @Inject constructor( override fun onCleared() { super.onCleared() - partnerAnswerListener?.remove() + partnerObserverJob?.cancel() } private companion object { diff --git a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt index ab9d06e1..2c506ced 100644 --- a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt @@ -14,14 +14,13 @@ import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.usecase.DailyQuestionResolver -import com.google.firebase.firestore.FirebaseFirestore -import com.google.firebase.firestore.ListenerRegistration import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Job import kotlinx.coroutines.launch data class LocalQuestionUiState( @@ -47,19 +46,18 @@ class DailyQuestionViewModel @Inject constructor( private val coupleRepository: CoupleRepository, private val crashReporter: CrashReporter, private val dailyQuestionResolver: DailyQuestionResolver, - private val retentionAnalytics: RetentionAnalytics, - private val db: FirebaseFirestore + private val retentionAnalytics: RetentionAnalytics ) : ViewModel() { private val _uiState = MutableStateFlow(LocalQuestionUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private var partnerAnswerListener: ListenerRegistration? = null + // Cancelled/replaced each time the observer (re)starts; auto-cancelled with viewModelScope. + private var partnerObserverJob: Job? = null override fun onCleared() { super.onCleared() - partnerAnswerListener?.remove() - partnerAnswerListener = null + partnerObserverJob?.cancel() } init { @@ -124,21 +122,14 @@ class DailyQuestionViewModel @Inject constructor( } private fun startPartnerAnswerObserver(coupleId: String, date: String) { - viewModelScope.launch { + partnerObserverJob?.cancel() + partnerObserverJob = viewModelScope.launch { val userId = authRepository.currentUserId ?: return@launch val couple = runCatching { coupleRepository.getCoupleForUser(userId) }.getOrNull() ?: return@launch val partnerId = couple.userIds.firstOrNull { it != userId } ?: return@launch - partnerAnswerListener?.remove() - partnerAnswerListener = db.collection("couples") - .document(coupleId) - .collection("daily_question") - .document(date) - .collection("answers") - .document(partnerId) - .addSnapshotListener { snapshot, error -> - if (error != null) return@addSnapshotListener - _uiState.update { it.copy(partnerHasAnswered = snapshot?.exists() == true) } - } + firestoreAnswerDataSource.observeAnswerForUser(coupleId, partnerId, date).collect { partnerAnswer -> + _uiState.update { it.copy(partnerHasAnswered = partnerAnswer != null) } + } } }