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
|
||||
)
|
||||
|
||||
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,
|
||||
|
|
@ -77,7 +85,11 @@ data class HomeUiState(
|
|||
val secondaryActions: List<HomeAction> = emptyList(),
|
||||
val partnerLeftEvent: 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
|
||||
|
|
@ -95,6 +107,7 @@ class HomeViewModel @Inject constructor(
|
|||
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()
|
||||
|
|
@ -136,8 +149,8 @@ class HomeViewModel @Inject constructor(
|
|||
couple.encryptionMigrationUsers[uid] != true
|
||||
else -> false
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
isLoading = false,
|
||||
dailyQuestion = dailyQuestion,
|
||||
categories = categories,
|
||||
|
|
@ -147,8 +160,9 @@ class HomeViewModel @Inject constructor(
|
|||
partnerLeftEvent = false,
|
||||
needsRecovery = needsRecovery,
|
||||
needsEncryptionUpgrade = needsEncryptionUpgrade
|
||||
).withHomeActions()
|
||||
).refreshDailyQuestionState().withHomeActions()
|
||||
}
|
||||
observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id)
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
|
|
@ -216,12 +230,67 @@ class HomeViewModel @Inject constructor(
|
|||
latest = sorted.firstOrNull(),
|
||||
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 {
|
||||
if (isLoading || error != null) {
|
||||
return copy(primaryAction = null, secondaryActions = emptyList())
|
||||
|
|
@ -235,10 +304,11 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun HomeUiState.buildPrimaryAction(): HomeAction {
|
||||
val dailyQuestionIsUnanswered = dailyQuestion
|
||||
?.id
|
||||
?.let { questionId -> questionId !in answerStats.answeredQuestionIds }
|
||||
?: false
|
||||
val dailyQuestionId = dailyQuestion?.id
|
||||
val userAnswered = dailyQuestionId != null && dailyQuestionId in answerStats.answeredQuestionIds
|
||||
val userRevealed = dailyQuestionId != null && answerStats.latest?.let { latest ->
|
||||
latest.questionId == dailyQuestionId && latest.isRevealed
|
||||
} == true
|
||||
|
||||
return when {
|
||||
!isPaired -> HomeAction(
|
||||
|
|
@ -250,7 +320,47 @@ class HomeViewModel @Inject constructor(
|
|||
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",
|
||||
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.",
|
||||
|
|
@ -282,11 +392,12 @@ class HomeViewModel @Inject constructor(
|
|||
|
||||
else -> HomeAction(
|
||||
eyebrow = "Gentle start",
|
||||
title = "Start with one question worth answering.",
|
||||
body = "A small prompt is enough. Build the habit around attention, not pressure.",
|
||||
cta = "Start tonight",
|
||||
title = "Tonight's question is ready.",
|
||||
body = "${dailyQuestion?.text ?: "A small prompt is enough. Build the habit around attention, not pressure."}",
|
||||
cta = "Answer privately",
|
||||
target = HomeActionTarget.DailyQuestion,
|
||||
tone = HomeActionTone.Starter
|
||||
tone = HomeActionTone.Starter,
|
||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue