refactor(ui): remove raw Firestore listeners from Daily/Partner VMs

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 22:18:30 -05:00
parent cce2687312
commit 0c59000065
2 changed files with 20 additions and 34 deletions

View File

@ -59,7 +59,6 @@ import app.closer.ui.components.LoadingState
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.closerCardColor import app.closer.ui.theme.closerCardColor
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.functions.FirebaseFunctions import com.google.firebase.functions.FirebaseFunctions
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
@ -67,6 +66,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import app.closer.ui.components.CloserGlyphs import app.closer.ui.components.CloserGlyphs
@ -91,14 +91,14 @@ class PartnerHomeViewModel @Inject constructor(
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val answerDataSource: FirestoreAnswerDataSource, private val answerDataSource: FirestoreAnswerDataSource,
private val db: FirebaseFirestore,
private val functions: FirebaseFunctions, private val functions: FirebaseFunctions,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(PartnerHomeUiState()) private val _uiState = MutableStateFlow(PartnerHomeUiState())
val uiState: StateFlow<PartnerHomeUiState> = _uiState.asStateFlow() val uiState: StateFlow<PartnerHomeUiState> = _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 { init {
load() load()
@ -148,21 +148,16 @@ class PartnerHomeViewModel @Inject constructor(
} }
private fun observePartnerAnswer(coupleId: String, userIds: List<String>, questionId: String?) { private fun observePartnerAnswer(coupleId: String, userIds: List<String>, questionId: String?) {
partnerAnswerListener?.remove() partnerObserverJob?.cancel()
val uid = authRepository.currentUserId ?: return val uid = authRepository.currentUserId ?: return
val partnerId = userIds.firstOrNull { it != uid } ?: return val partnerId = userIds.firstOrNull { it != uid } ?: return
val qId = questionId ?: return if (questionId == null) return
val today = FirestoreAnswerDataSource.todayLocalDateString() val today = FirestoreAnswerDataSource.todayLocalDateString()
partnerAnswerListener = db.collection("couples").document(coupleId) partnerObserverJob = viewModelScope.launch {
.collection("daily_question").document(today) answerDataSource.observeAnswerForUser(coupleId, partnerId, today).collect { partnerAnswer ->
.collection("answers").document(partnerId) _uiState.update { it.copy(hasPartnerAnsweredToday = partnerAnswer != null) }
.addSnapshotListener { snapshot, error ->
if (error != null) {
Log.w(TAG, "Partner answer listener error", error)
return@addSnapshotListener
}
_uiState.update { it.copy(hasPartnerAnsweredToday = snapshot?.exists() == true) }
} }
}
} }
fun sendReminder() { fun sendReminder() {
@ -185,7 +180,7 @@ class PartnerHomeViewModel @Inject constructor(
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
partnerAnswerListener?.remove() partnerObserverJob?.cancel()
} }
private companion object { private companion object {

View File

@ -14,14 +14,13 @@ import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.usecase.DailyQuestionResolver 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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class LocalQuestionUiState( data class LocalQuestionUiState(
@ -47,19 +46,18 @@ class DailyQuestionViewModel @Inject constructor(
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val crashReporter: CrashReporter, private val crashReporter: CrashReporter,
private val dailyQuestionResolver: DailyQuestionResolver, private val dailyQuestionResolver: DailyQuestionResolver,
private val retentionAnalytics: RetentionAnalytics, private val retentionAnalytics: RetentionAnalytics
private val db: FirebaseFirestore
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(LocalQuestionUiState()) private val _uiState = MutableStateFlow(LocalQuestionUiState())
val uiState: StateFlow<LocalQuestionUiState> = _uiState.asStateFlow() val uiState: StateFlow<LocalQuestionUiState> = _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() { override fun onCleared() {
super.onCleared() super.onCleared()
partnerAnswerListener?.remove() partnerObserverJob?.cancel()
partnerAnswerListener = null
} }
init { init {
@ -124,21 +122,14 @@ class DailyQuestionViewModel @Inject constructor(
} }
private fun startPartnerAnswerObserver(coupleId: String, date: String) { private fun startPartnerAnswerObserver(coupleId: String, date: String) {
viewModelScope.launch { partnerObserverJob?.cancel()
partnerObserverJob = viewModelScope.launch {
val userId = authRepository.currentUserId ?: return@launch val userId = authRepository.currentUserId ?: return@launch
val couple = runCatching { coupleRepository.getCoupleForUser(userId) }.getOrNull() ?: return@launch val couple = runCatching { coupleRepository.getCoupleForUser(userId) }.getOrNull() ?: return@launch
val partnerId = couple.userIds.firstOrNull { it != userId } ?: return@launch val partnerId = couple.userIds.firstOrNull { it != userId } ?: return@launch
partnerAnswerListener?.remove() firestoreAnswerDataSource.observeAnswerForUser(coupleId, partnerId, date).collect { partnerAnswer ->
partnerAnswerListener = db.collection("couples") _uiState.update { it.copy(partnerHasAnswered = partnerAnswer != null) }
.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) }
}
} }
} }