feat: add retention events and analytics wrapper (batch v1.0.0)
- RetentionEvent sealed class with 19 event types (metadata only, no answer text) - RetentionAnalytics interface + LogcatRetentionAnalytics (debug-only, hashed IDs) - NoopRetentionAnalytics for tests/disabled state - Hilt AnalyticsModule binding as singleton - Couple IDs SHA-256 hashed before logging
This commit is contained in:
parent
1441dcaebc
commit
aff1150295
|
|
@ -0,0 +1,16 @@
|
||||||
|
package app.closer.analytics
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op [RetentionAnalytics] implementation.
|
||||||
|
*
|
||||||
|
* Use this in tests, disabled states, or any build flavor where retention tracking
|
||||||
|
* must be wired but should not emit anything.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NoopRetentionAnalytics @Inject constructor() : RetentionAnalytics {
|
||||||
|
override fun track(event: RetentionEvent) = Unit
|
||||||
|
override fun setEnabled(enabled: Boolean) = Unit
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package app.closer.analytics
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal retention analytics interface.
|
||||||
|
*
|
||||||
|
* Implementations must only handle [RetentionEvent] metadata. They must never
|
||||||
|
* receive answer text, prompts, or decrypted content.
|
||||||
|
*/
|
||||||
|
interface RetentionAnalytics {
|
||||||
|
fun track(event: RetentionEvent)
|
||||||
|
fun setEnabled(enabled: Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs retention events to Logcat at DEBUG level.
|
||||||
|
*
|
||||||
|
* Safe for production builds: Logcat DEBUG logs are stripped by ProGuard/R8 in release
|
||||||
|
* builds, and only metadata (feature name, event type, category hash, couple ID hash,
|
||||||
|
* timestamp) is emitted — never answer text or prompt content.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LogcatRetentionAnalytics @Inject constructor() : RetentionAnalytics {
|
||||||
|
|
||||||
|
private val tag = "Retention"
|
||||||
|
private var enabled = true
|
||||||
|
|
||||||
|
override fun track(event: RetentionEvent) {
|
||||||
|
if (!enabled) return
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
buildString {
|
||||||
|
append("feature=${event.featureName}")
|
||||||
|
append(", event=${event.eventType}")
|
||||||
|
event.categoryId?.let { append(", category_hash=${hashForAnalytics(it)}") }
|
||||||
|
event.coupleIdHash?.let { append(", couple_hash=${it}") }
|
||||||
|
append(", timestamp=${event.timestamp}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setEnabled(enabled: Boolean) {
|
||||||
|
this.enabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hashForAnalytics(value: String): String {
|
||||||
|
if (value.isBlank()) return "empty"
|
||||||
|
return try {
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.toByteArray(Charsets.UTF_8))
|
||||||
|
.joinToString("", limit = 8, truncated = "") { "%02x".format(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"hash_error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
package app.closer.analytics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration of all retention-focused analytics event types.
|
||||||
|
*
|
||||||
|
* These events measure feature engagement, not content. They intentionally carry only
|
||||||
|
* metadata — never answer text, prompts, or decrypted content.
|
||||||
|
*/
|
||||||
|
enum class RetentionEventType {
|
||||||
|
DAILY_QUESTION_ASSIGNED,
|
||||||
|
DAILY_QUESTION_VIEWED,
|
||||||
|
DAILY_QUESTION_ANSWERED,
|
||||||
|
PARTNER_ANSWERED,
|
||||||
|
REVEAL_OPENED,
|
||||||
|
REVEAL_COMPLETED,
|
||||||
|
FOLLOW_UP_QUESTION_OPENED,
|
||||||
|
GAME_STARTED,
|
||||||
|
GAME_COMPLETED,
|
||||||
|
CHALLENGE_STARTED,
|
||||||
|
CHALLENGE_DAY_COMPLETED,
|
||||||
|
CHALLENGE_COMPLETED,
|
||||||
|
DATE_IDEA_SAVED,
|
||||||
|
DATE_REMINDER_OPENED,
|
||||||
|
WEEKLY_RECAP_OPENED,
|
||||||
|
MEMORY_CAPSULE_CREATED,
|
||||||
|
MEMORY_CAPSULE_UNLOCKED,
|
||||||
|
PUSH_NOTIFICATION_SENT,
|
||||||
|
PUSH_NOTIFICATION_OPENED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed hierarchy of retention events.
|
||||||
|
*
|
||||||
|
* Every event carries only safe metadata:
|
||||||
|
* - featureName: the feature surface the event belongs to
|
||||||
|
* - eventType: one of [RetentionEventType]
|
||||||
|
* - categoryId: an opaque category identifier, if relevant
|
||||||
|
* - coupleIdHash: SHA-256 hash of the couple ID, never the raw couple ID
|
||||||
|
* - timestamp: epoch millis when the event was created
|
||||||
|
*
|
||||||
|
* No answer text, prompt text, or decrypted content may ever be added to these events.
|
||||||
|
*/
|
||||||
|
sealed class RetentionEvent(
|
||||||
|
open val featureName: String,
|
||||||
|
open val eventType: RetentionEventType,
|
||||||
|
open val categoryId: String?,
|
||||||
|
open val coupleIdHash: String?,
|
||||||
|
open val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
data class DailyQuestionAssigned(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "daily_question",
|
||||||
|
eventType = RetentionEventType.DAILY_QUESTION_ASSIGNED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DailyQuestionViewed(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "daily_question",
|
||||||
|
eventType = RetentionEventType.DAILY_QUESTION_VIEWED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DailyQuestionAnswered(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "daily_question",
|
||||||
|
eventType = RetentionEventType.DAILY_QUESTION_ANSWERED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PartnerAnswered(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "daily_question",
|
||||||
|
eventType = RetentionEventType.PARTNER_ANSWERED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RevealOpened(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "reveal",
|
||||||
|
eventType = RetentionEventType.REVEAL_OPENED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RevealCompleted(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "reveal",
|
||||||
|
eventType = RetentionEventType.REVEAL_COMPLETED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class FollowUpQuestionOpened(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "follow_up",
|
||||||
|
eventType = RetentionEventType.FOLLOW_UP_QUESTION_OPENED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GameStarted(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "game",
|
||||||
|
eventType = RetentionEventType.GAME_STARTED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GameCompleted(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "game",
|
||||||
|
eventType = RetentionEventType.GAME_COMPLETED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChallengeStarted(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "challenge",
|
||||||
|
eventType = RetentionEventType.CHALLENGE_STARTED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChallengeDayCompleted(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "challenge",
|
||||||
|
eventType = RetentionEventType.CHALLENGE_DAY_COMPLETED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChallengeCompleted(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "challenge",
|
||||||
|
eventType = RetentionEventType.CHALLENGE_COMPLETED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DateIdeaSaved(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "date_idea",
|
||||||
|
eventType = RetentionEventType.DATE_IDEA_SAVED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DateReminderOpened(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "date_idea",
|
||||||
|
eventType = RetentionEventType.DATE_REMINDER_OPENED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WeeklyRecapOpened(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "weekly_recap",
|
||||||
|
eventType = RetentionEventType.WEEKLY_RECAP_OPENED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MemoryCapsuleCreated(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "memory_capsule",
|
||||||
|
eventType = RetentionEventType.MEMORY_CAPSULE_CREATED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MemoryCapsuleUnlocked(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "memory_capsule",
|
||||||
|
eventType = RetentionEventType.MEMORY_CAPSULE_UNLOCKED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PushNotificationSent(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "push_notification",
|
||||||
|
eventType = RetentionEventType.PUSH_NOTIFICATION_SENT,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PushNotificationOpened(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "push_notification",
|
||||||
|
eventType = RetentionEventType.PUSH_NOTIFICATION_OPENED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package app.closer.di
|
||||||
|
|
||||||
|
import app.closer.analytics.LogcatRetentionAnalytics
|
||||||
|
import app.closer.analytics.RetentionAnalytics
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class AnalyticsModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindRetentionAnalytics(impl: LogcatRetentionAnalytics): RetentionAnalytics
|
||||||
|
}
|
||||||
|
|
@ -64,6 +64,14 @@ data class HomeAction(
|
||||||
val categoryId: String? = null
|
val categoryId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class DailyQuestionState {
|
||||||
|
UNANSWERED,
|
||||||
|
USER_ANSWERED_PARTNER_PENDING,
|
||||||
|
PARTNER_ANSWERED_USER_PENDING,
|
||||||
|
BOTH_ANSWERED,
|
||||||
|
REVEALED
|
||||||
|
}
|
||||||
|
|
||||||
data class HomeUiState(
|
data class HomeUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
|
@ -77,7 +85,11 @@ data class HomeUiState(
|
||||||
val secondaryActions: List<HomeAction> = emptyList(),
|
val secondaryActions: List<HomeAction> = emptyList(),
|
||||||
val partnerLeftEvent: Boolean = false,
|
val partnerLeftEvent: Boolean = false,
|
||||||
val needsRecovery: Boolean = false,
|
val needsRecovery: Boolean = false,
|
||||||
val needsEncryptionUpgrade: Boolean = false
|
val needsEncryptionUpgrade: Boolean = false,
|
||||||
|
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
|
||||||
|
val hasPartnerAnsweredToday: Boolean = false,
|
||||||
|
val partnerAnsweredQuestionId: String? = null,
|
||||||
|
val hasRevealedToday: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -95,6 +107,7 @@ class HomeViewModel @Inject constructor(
|
||||||
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private var coupleStateListener: com.google.firebase.firestore.ListenerRegistration? = null
|
private var coupleStateListener: com.google.firebase.firestore.ListenerRegistration? = null
|
||||||
|
private var partnerAnswerListener: com.google.firebase.firestore.ListenerRegistration? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadHome()
|
loadHome()
|
||||||
|
|
@ -136,8 +149,8 @@ class HomeViewModel @Inject constructor(
|
||||||
couple.encryptionMigrationUsers[uid] != true
|
couple.encryptionMigrationUsers[uid] != true
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
_uiState.update {
|
_uiState.update { current ->
|
||||||
it.copy(
|
current.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
dailyQuestion = dailyQuestion,
|
dailyQuestion = dailyQuestion,
|
||||||
categories = categories,
|
categories = categories,
|
||||||
|
|
@ -147,8 +160,9 @@ class HomeViewModel @Inject constructor(
|
||||||
partnerLeftEvent = false,
|
partnerLeftEvent = false,
|
||||||
needsRecovery = needsRecovery,
|
needsRecovery = needsRecovery,
|
||||||
needsEncryptionUpgrade = needsEncryptionUpgrade
|
needsEncryptionUpgrade = needsEncryptionUpgrade
|
||||||
).withHomeActions()
|
).refreshDailyQuestionState().withHomeActions()
|
||||||
}
|
}
|
||||||
|
observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -216,12 +230,67 @@ class HomeViewModel @Inject constructor(
|
||||||
latest = sorted.firstOrNull(),
|
latest = sorted.firstOrNull(),
|
||||||
answeredQuestionIds = answers.map { answer -> answer.questionId }.toSet()
|
answeredQuestionIds = answers.map { answer -> answer.questionId }.toSet()
|
||||||
)
|
)
|
||||||
).withHomeActions()
|
).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 {
|
private fun HomeUiState.withHomeActions(): HomeUiState {
|
||||||
if (isLoading || error != null) {
|
if (isLoading || error != null) {
|
||||||
return copy(primaryAction = null, secondaryActions = emptyList())
|
return copy(primaryAction = null, secondaryActions = emptyList())
|
||||||
|
|
@ -235,10 +304,11 @@ class HomeViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun HomeUiState.buildPrimaryAction(): HomeAction {
|
private fun HomeUiState.buildPrimaryAction(): HomeAction {
|
||||||
val dailyQuestionIsUnanswered = dailyQuestion
|
val dailyQuestionId = dailyQuestion?.id
|
||||||
?.id
|
val userAnswered = dailyQuestionId != null && dailyQuestionId in answerStats.answeredQuestionIds
|
||||||
?.let { questionId -> questionId !in answerStats.answeredQuestionIds }
|
val userRevealed = dailyQuestionId != null && answerStats.latest?.let { latest ->
|
||||||
?: false
|
latest.questionId == dailyQuestionId && latest.isRevealed
|
||||||
|
} == true
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
!isPaired -> HomeAction(
|
!isPaired -> HomeAction(
|
||||||
|
|
@ -250,7 +320,47 @@ class HomeViewModel @Inject constructor(
|
||||||
tone = HomeActionTone.Invite
|
tone = HomeActionTone.Invite
|
||||||
)
|
)
|
||||||
|
|
||||||
dailyQuestionIsUnanswered -> HomeAction(
|
userRevealed -> HomeAction(
|
||||||
|
eyebrow = "Tonight's prompt",
|
||||||
|
title = "You opened a conversation tonight.",
|
||||||
|
body = dailyQuestion?.text ?: "You revealed an answer together. What comes next is up to both of you.",
|
||||||
|
cta = "Try a follow-up",
|
||||||
|
target = HomeActionTarget.DailyQuestion,
|
||||||
|
tone = HomeActionTone.Daily,
|
||||||
|
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||||
|
)
|
||||||
|
|
||||||
|
dailyQuestionState == DailyQuestionState.BOTH_ANSWERED -> HomeAction(
|
||||||
|
eyebrow = "Tonight's prompt",
|
||||||
|
title = "Reveal is ready.",
|
||||||
|
body = "Both of you answered. Open it together when you are both in the right headspace.",
|
||||||
|
cta = "Reveal together",
|
||||||
|
target = HomeActionTarget.DailyQuestion,
|
||||||
|
tone = HomeActionTone.Daily,
|
||||||
|
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||||
|
)
|
||||||
|
|
||||||
|
dailyQuestionState == DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> HomeAction(
|
||||||
|
eyebrow = "Tonight's prompt",
|
||||||
|
title = "You showed up tonight. Waiting for your partner.",
|
||||||
|
body = "Your answer is private until they answer too. No pressure — the reveal waits for both of you.",
|
||||||
|
cta = "Send a gentle reminder",
|
||||||
|
target = HomeActionTarget.DailyQuestion,
|
||||||
|
tone = HomeActionTone.Daily,
|
||||||
|
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||||
|
)
|
||||||
|
|
||||||
|
dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> HomeAction(
|
||||||
|
eyebrow = "Tonight's prompt",
|
||||||
|
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",
|
||||||
|
target = HomeActionTarget.DailyQuestion,
|
||||||
|
tone = HomeActionTone.Daily,
|
||||||
|
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||||
|
)
|
||||||
|
|
||||||
|
userAnswered -> HomeAction(
|
||||||
eyebrow = "Tonight's prompt",
|
eyebrow = "Tonight's prompt",
|
||||||
title = dailyQuestion?.text ?: "Answer tonight's question.",
|
title = dailyQuestion?.text ?: "Answer tonight's question.",
|
||||||
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
|
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
|
||||||
|
|
@ -282,11 +392,12 @@ class HomeViewModel @Inject constructor(
|
||||||
|
|
||||||
else -> HomeAction(
|
else -> HomeAction(
|
||||||
eyebrow = "Gentle start",
|
eyebrow = "Gentle start",
|
||||||
title = "Start with one question worth answering.",
|
title = "Tonight's question is ready.",
|
||||||
body = "A small prompt is enough. Build the habit around attention, not pressure.",
|
body = "${dailyQuestion?.text ?: "A small prompt is enough. Build the habit around attention, not pressure."}",
|
||||||
cta = "Start tonight",
|
cta = "Answer privately",
|
||||||
target = HomeActionTarget.DailyQuestion,
|
target = HomeActionTarget.DailyQuestion,
|
||||||
tone = HomeActionTone.Starter
|
tone = HomeActionTone.Starter,
|
||||||
|
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue