feat: daily questions, answer reveal, home screens, auth, analytics, DB, repositories

This commit is contained in:
null 2026-06-22 17:45:51 -05:00
parent 174e56c5a0
commit 5d3ab8385d
17 changed files with 492 additions and 24 deletions

1
.gitignore vendored
View File

@ -65,3 +65,4 @@ app/GoogleService-Info.plist
docs/SUBSCRIPTION_GO_LIVE.md
ios_encrypt.md
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json
DAILY_FUN_IMPLEMENTATION_BATCH_PLAN.md

Binary file not shown.

View File

@ -2,6 +2,7 @@ package app.closer
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
@ -22,13 +23,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import app.closer.BuildConfig
import app.closer.core.navigation.AppNavigation
import app.closer.domain.repository.AppSettings
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.SettingsRepository
import app.closer.domain.repository.ThemeMode
import app.closer.ui.theme.CloserTheme
import com.google.firebase.auth.FirebaseAuth
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import javax.inject.Inject
@AndroidEntryPoint
@ -38,6 +42,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (BuildConfig.DEBUG) attemptDebugAutoLogin()
setContent {
val settings by settingsRepository.settings.collectAsState(initial = AppSettings())
val systemInDarkTheme = isSystemInDarkTheme()
@ -80,6 +85,18 @@ class MainActivity : AppCompatActivity() {
super.onNewIntent(intent)
setIntent(intent)
}
private fun attemptDebugAutoLogin() {
val tokenFile = File(filesDir, "closer_debug_token.txt")
if (!tokenFile.exists()) return
val token = tokenFile.readText().trim()
tokenFile.delete()
if (token.isBlank()) return
Log.d("CloserDebug", "Signing in with debug custom token")
FirebaseAuth.getInstance().signInWithCustomToken(token)
.addOnSuccessListener { Log.d("CloserDebug", "Debug sign-in succeeded") }
.addOnFailureListener { e -> Log.e("CloserDebug", "Debug sign-in failed", e) }
}
}
@Composable

View File

@ -26,6 +26,10 @@ enum class RetentionEventType {
MEMORY_CAPSULE_UNLOCKED,
PUSH_NOTIFICATION_SENT,
PUSH_NOTIFICATION_OPENED,
DAILY_MODE_RESOLVED,
DAILY_TINY_ACTION_VIEWED,
DAILY_TINY_ACTION_SAVED,
COUPLE_LORE_SAVED,
}
/**
@ -274,4 +278,52 @@ sealed class RetentionEvent(
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class DailyModeResolved(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "daily_question",
eventType = RetentionEventType.DAILY_MODE_RESOLVED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class DailyTinyActionViewed(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "daily_question",
eventType = RetentionEventType.DAILY_TINY_ACTION_VIEWED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class DailyTinyActionSaved(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "daily_question",
eventType = RetentionEventType.DAILY_TINY_ACTION_SAVED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
data class CoupleLoreSaved(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "couple_lore",
eventType = RetentionEventType.COUPLE_LORE_SAVED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
}

View File

@ -15,6 +15,18 @@ interface QuestionDao {
@Query("SELECT * FROM question WHERE status = 'active' AND is_premium = 0 AND TRIM(text) <> '' AND category_id <> 'unknown' ORDER BY RANDOM() LIMIT 1")
suspend fun getDailyQuestion(): QuestionEntity?
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
suspend fun getDailyQuestionByModeTag(modeTag: String): QuestionEntity?
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
suspend fun getDailyQuestionFromPack(): QuestionEntity?
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
suspend fun getFreeDailyQuestionByModeTag(modeTag: String): QuestionEntity?
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
suspend fun getFreeDailyQuestionFromPack(): QuestionEntity?
@Query("SELECT * FROM question WHERE category_id = :categoryId AND status = 'active' AND TRIM(text) <> '' ORDER BY depth_level ASC, id ASC")
suspend fun getQuestionsByCategory(categoryId: String): List<QuestionEntity>

View File

@ -1,5 +1,6 @@
package app.closer.data.remote
import app.closer.BuildConfig
import app.closer.domain.model.AuthState
import app.closer.domain.model.GoogleSignInResult
import com.google.firebase.auth.EmailAuthProvider
@ -17,7 +18,9 @@ import kotlin.coroutines.resumeWithException
@Singleton
class FirebaseAuthDataSource @Inject constructor() {
private val auth = FirebaseAuth.getInstance()
private val auth = FirebaseAuth.getInstance().also {
if (BuildConfig.DEBUG) it.firebaseAuthSettings.setAppVerificationDisabledForTesting(true)
}
val currentUserId: String? get() = auth.currentUser?.uid
val currentUserEmail: String? get() = auth.currentUser?.email

View File

@ -231,6 +231,37 @@ class FirestoreAnswerDataSource @Inject constructor(
)
}
/**
* Saves a "Couple Lore" entry to Firestore.
* Path: couples/{coupleId}/lore/{questionId}
*/
suspend fun saveLoreEntry(
coupleId: String,
questionId: String,
questionText: String,
ownAnswer: String,
partnerAnswer: String?,
modeTag: String?,
date: String
): Unit = suspendCancellableCoroutine { cont ->
val doc = mapOf(
"questionId" to questionId,
"questionText" to questionText,
"ownAnswer" to ownAnswer,
"partnerAnswer" to partnerAnswer,
"modeTag" to modeTag,
"date" to date,
"savedAt" to com.google.firebase.Timestamp.now()
)
db.collection(FirestoreCollections.COUPLES)
.document(coupleId)
.collection("lore")
.document(questionId)
.set(doc)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
data class DailyQuestionAssignment(
val questionId: String,
val date: String,

View File

@ -6,6 +6,7 @@ import app.closer.domain.repository.QuestionRepository
class FakeQuestionRepository : QuestionRepository {
override suspend fun getDailyQuestion(): Question? = null
override suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question? = null
override suspend fun getQuestionById(id: String): Question? = null

View File

@ -19,6 +19,18 @@ class RoomQuestionRepository @Inject constructor(
return questionDao.getDailyQuestion()?.toQuestion()
}
override suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question? {
return if (isPremium) {
questionDao.getDailyQuestionByModeTag(modeTag)?.toQuestion()
?: questionDao.getDailyQuestionFromPack()?.toQuestion()
?: questionDao.getDailyQuestion()?.toQuestion()
} else {
questionDao.getFreeDailyQuestionByModeTag(modeTag)?.toQuestion()
?: questionDao.getFreeDailyQuestionFromPack()?.toQuestion()
?: questionDao.getDailyQuestion()?.toQuestion()
}
}
override suspend fun getQuestionById(id: String): Question? {
return questionDao.getQuestionById(id)?.toQuestion()
}

View File

@ -0,0 +1,119 @@
package app.closer.domain
import java.util.Calendar
/**
* Resolves which daily mode to use for the current day.
*
* Day-of-week defaults are deterministic so the same mode always shows on
* the same calendar day. A ~10% wildcard override keeps things surprising.
*/
object DailyModeResolver {
data class DailyMode(
val id: String,
val title: String,
val subtitle: String,
val actionCopy: String,
val modeTag: String
)
private val MODES = mapOf(
"soft_monday" to DailyMode(
id = "soft_monday",
title = "Soft Monday",
subtitle = "Tiny, easy, still us.",
actionCopy = "Tiny mission: make tonight easier on purpose.",
modeTag = "mode_soft_monday"
),
"snack_mission" to DailyMode(
id = "snack_mission",
title = "Snack Mission",
subtitle = "The snack is part of the plot.",
actionCopy = "Snack mission: find one treat worth sharing.",
modeTag = "mode_snack_mission"
),
"no_phone_moment" to DailyMode(
id = "no_phone_moment",
title = "No-Phone Moment",
subtitle = "Just a tiny bit of us.",
actionCopy = "No-phone moment: 10 minutes, just us.",
modeTag = "mode_no_phone_moment"
),
"laugh_reset" to DailyMode(
id = "laugh_reset",
title = "Laugh Reset",
subtitle = "Pressure off. Weird on.",
actionCopy = "Laugh reset: tell today's nonsense dramatically.",
modeTag = "mode_laugh_reset"
),
"flirty_friday" to DailyMode(
id = "flirty_friday",
title = "Flirty Friday",
subtitle = "A little spark, no pressure.",
actionCopy = "Tiny mission: send one flirty text.",
modeTag = "mode_flirty_friday"
),
"weekend_side_quest" to DailyMode(
id = "weekend_side_quest",
title = "Weekend Side Quest",
subtitle = "Pick today's tiny mission.",
actionCopy = "Side quest: do one tiny thing outside the usual.",
modeTag = "mode_weekend_side_quest"
),
"tiny_date_night" to DailyMode(
id = "tiny_date_night",
title = "Tiny Date Night",
subtitle = "Small plan. Big maybe.",
actionCopy = "Tiny mission: make one ordinary thing feel like a date.",
modeTag = "mode_tiny_date_night"
),
"couple_lore_day" to DailyMode(
id = "couple_lore_day",
title = "Couple Lore Day",
subtitle = "Add to the us-history.",
actionCopy = "Couple lore: name this tiny moment.",
modeTag = "mode_couple_lore_day"
),
"low_battery_day" to DailyMode(
id = "low_battery_day",
title = "Low Battery Day",
subtitle = "Easy counts today.",
actionCopy = "Tiny mission: lower the bar together.",
modeTag = "mode_low_battery_day"
),
"wildcard" to DailyMode(
id = "wildcard",
title = "Wildcard",
subtitle = "The app has opinions.",
actionCopy = "Wildcard mission: say yes to one tiny weird idea.",
modeTag = "mode_wildcard"
),
)
private val DOW_DEFAULTS = mapOf(
Calendar.MONDAY to "soft_monday",
Calendar.TUESDAY to "snack_mission",
Calendar.WEDNESDAY to "no_phone_moment",
Calendar.THURSDAY to "laugh_reset",
Calendar.FRIDAY to "flirty_friday",
Calendar.SATURDAY to "weekend_side_quest",
Calendar.SUNDAY to "tiny_date_night",
)
fun resolve(calendar: Calendar = Calendar.getInstance()): DailyMode {
val dow = calendar.get(Calendar.DAY_OF_WEEK)
// ~10% wildcard using day-of-year so it's repeatable within the same day
val doy = calendar.get(Calendar.DAY_OF_YEAR)
val modeId = if (doy % 10 == 3) {
"wildcard"
} else {
DOW_DEFAULTS[dow] ?: "tiny_date_night"
}
return MODES[modeId] ?: MODES["tiny_date_night"]!!
}
fun getMode(id: String): DailyMode? = MODES[id]
fun allModes(): Collection<DailyMode> = MODES.values
}

View File

@ -5,6 +5,7 @@ import app.closer.domain.model.QuestionCategory
interface QuestionRepository {
suspend fun getDailyQuestion(): Question?
suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question?
suspend fun getQuestionById(id: String): Question?
suspend fun getQuestionsByCategory(categoryId: String): List<Question>
suspend fun getCategories(): List<QuestionCategory>

View File

@ -51,6 +51,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question
import app.closer.domain.DailyModeResolver
import app.closer.ui.questions.displayCategoryName
import app.closer.ui.questions.displayQuestionType
import app.closer.ui.components.BrandMessageRotator
@ -83,6 +84,8 @@ fun AnswerRevealScreen(
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
onHome = { onNavigate(AppRoute.HOME) },
onFollowUpSelected = { option -> viewModel.onFollowUpSelected(option, onNavigate) },
onSaveLore = viewModel::saveCoupleLoré,
onTinyActionSaved = viewModel::onTinyActionSaved,
onSnackbarShown = viewModel::clearSnackbar
)
}
@ -97,6 +100,8 @@ private fun AnswerRevealContent(
onHome: () -> Unit,
onRefresh: () -> Unit = {},
onFollowUpSelected: (FollowUpOption) -> Unit = {},
onSaveLore: () -> Unit = {},
onTinyActionSaved: () -> Unit = {},
onSnackbarShown: () -> Unit = {}
) {
val context = LocalContext.current
@ -181,6 +186,11 @@ private fun AnswerRevealContent(
enter = if (reducedMotion) fadeIn(tween(0))
else fadeIn(tween(380)) + expandVertically(tween(380))
) {
val tinyActionMode = state.question?.tags
?.firstOrNull { it.startsWith("mode_") }
?.removePrefix("mode_")
?.let { DailyModeResolver.getMode(it) }
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
RevealedState(
answer = state.answer,
@ -188,8 +198,16 @@ private fun AnswerRevealContent(
question = state.question,
onHistory = onHistory,
onHome = onHome,
onSaveLore = onSaveLore,
loreSaved = state.loreSaved,
wasSealed = state.answer.schemaVersion == 3
)
if (tinyActionMode != null) {
TinyActionCard(
mode = tinyActionMode,
onDoThis = onTinyActionSaved
)
}
if (state.followUpOptions.isNotEmpty()) {
FollowUpSection(
options = state.followUpOptions,
@ -321,6 +339,29 @@ private fun ReadyToRevealState(
// ── Sealed-answer reveal states (Batch 11) ───────────────────────────────────
private val waitingCopy = listOf(
"Your answer is in. Now we wait dramatically.",
"Locked in. Let's see what they choose.",
"You picked. The suspense is tiny but real.",
)
private val matchedCopy = listOf(
"You matched. Suspiciously cute.",
"Same pick. The lore deepens.",
"Both chose this. Tiny destiny.",
)
private val differentCopy = listOf(
"Different picks. Honestly, useful.",
"Two vibes entered the chat.",
"Not a match, but very on-brand.",
)
private fun dayIndexCopy(list: List<String>): String {
val doy = java.util.Calendar.getInstance().get(java.util.Calendar.DAY_OF_YEAR)
return list[doy % list.size]
}
@Composable
private fun AnswerSealedState(
question: Question?,
@ -338,10 +379,10 @@ private fun AnswerSealedState(
overflow = TextOverflow.Ellipsis
)
Text(
text = "Waiting for your partner. Reveal opens once both of you have answered.",
text = dayIndexCopy(waitingCopy),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
OutlinedButton(
@ -373,10 +414,10 @@ private fun BothAnsweredSealedState(
overflow = TextOverflow.Ellipsis
)
Text(
text = "Reveal is ready. Open it when you're both here — answers are exchanged privately between your devices.",
text = "Both of you picked. Tap reveal to find out.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@ -512,8 +553,20 @@ private fun RevealedState(
question: Question?,
onHistory: () -> Unit,
onHome: () -> Unit,
onSaveLore: () -> Unit = {},
loreSaved: Boolean = false,
wasSealed: Boolean = false
) {
val matchLine = if (partnerAnswer != null) {
val ownIds = answer.selectedOptionIds.toSet()
val partnerIds = partnerAnswer.selectedOptionIds.toSet()
when {
ownIds.isNotEmpty() && ownIds == partnerIds -> dayIndexCopy(matchedCopy)
ownIds.isNotEmpty() && partnerIds.isNotEmpty() -> dayIndexCopy(differentCopy)
else -> null
}
} else null
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@ -521,6 +574,15 @@ private fun RevealedState(
RevealPill(answer.category.displayCategoryName())
RevealPill(answer.answerType.displayQuestionType())
}
if (matchLine != null) {
Text(
text = matchLine,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF56306F),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = question?.text ?: answer.questionText,
style = MaterialTheme.typography.titleLarge,
@ -555,6 +617,69 @@ private fun RevealedState(
Text("Home")
}
}
if (!loreSaved) {
androidx.compose.material3.TextButton(
onClick = onSaveLore,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Save to Couple Lore",
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF56306F).copy(alpha = 0.75f)
)
}
} else {
Text(
text = "Saved to Couple Lore.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
}
@Composable
private fun TinyActionCard(
mode: DailyModeResolver.DailyMode,
onDoThis: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF0F8)),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = mode.actionCopy,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF56306F),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(
onClick = onDoThis,
modifier = Modifier
.weight(1f)
.heightIn(min = 40.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF24122F)
)
) {
Text("Do this tonight", style = MaterialTheme.typography.labelMedium)
}
}
}
}
}

View File

@ -3,6 +3,8 @@ package app.closer.ui.answers
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.analytics.RetentionAnalytics
import app.closer.analytics.RetentionEvent
import app.closer.core.navigation.AppRoute
import app.closer.core.crash.CrashReporter
import app.closer.crypto.PendingAnswerKeyStore
@ -61,7 +63,8 @@ data class AnswerRevealUiState(
val partnerId: String? = null,
val followUpOptions: List<FollowUpOption> = emptyList(),
val snackbarMessage: String? = null,
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE,
val loreSaved: Boolean = false
)
@HiltViewModel
@ -74,6 +77,7 @@ class AnswerRevealViewModel @Inject constructor(
private val crashReporter: CrashReporter,
private val sealedRevealManager: SealedRevealManager,
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
private val retentionAnalytics: RetentionAnalytics,
savedStateHandle: SavedStateHandle
) : ViewModel() {
@ -182,6 +186,10 @@ class AnswerRevealViewModel @Inject constructor(
fun revealAnswer() {
val state = _uiState.value
retentionAnalytics.track(RetentionEvent.RevealOpened(
categoryId = state.answer?.category,
coupleIdHash = state.coupleId?.coupleLoreHash()
))
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
performSealedReveal(state)
} else {
@ -269,6 +277,11 @@ class AnswerRevealViewModel @Inject constructor(
val ownAnswer = localAnswerRepository.getAnswer(questionId)
val category = ownAnswer?.category ?: state.question?.category ?: ""
retentionAnalytics.track(RetentionEvent.RevealCompleted(
categoryId = category,
coupleIdHash = state.coupleId?.coupleLoreHash()
))
_uiState.update {
it.copy(
answer = ownAnswer,
@ -285,6 +298,10 @@ class AnswerRevealViewModel @Inject constructor(
val answer = localAnswerRepository.getAnswer(questionId)
val partnerAnswer = _uiState.value.partnerAnswer
val category = answer?.category ?: _uiState.value.question?.category ?: ""
retentionAnalytics.track(RetentionEvent.RevealCompleted(
categoryId = category,
coupleIdHash = _uiState.value.coupleId?.coupleLoreHash()
))
_uiState.update {
it.copy(followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category))
}
@ -322,6 +339,40 @@ class AnswerRevealViewModel @Inject constructor(
route?.let { onNavigate(it) } ?: showSnackbar("Coming soon")
}
fun saveCoupleLoré() {
val state = _uiState.value
val coupleId = state.coupleId ?: return
val answer = state.answer ?: return
if (state.loreSaved) return
viewModelScope.launch {
val modeTag = state.question?.tags?.firstOrNull { it.startsWith("mode_") }
runCatching {
firestoreAnswerDataSource.saveLoreEntry(
coupleId = coupleId,
questionId = answer.questionId,
questionText = state.question?.text ?: answer.questionText,
ownAnswer = answer.revealSummaryText(),
partnerAnswer = state.partnerAnswer?.revealSummaryText(),
modeTag = modeTag,
date = effectiveDate(answer)
)
}.onFailure { crashReporter.recordException(it) }
_uiState.update { it.copy(loreSaved = true, snackbarMessage = "Saved to Couple Lore.") }
retentionAnalytics.track(RetentionEvent.CoupleLoreSaved(
categoryId = answer.category,
coupleIdHash = coupleId.coupleLoreHash()
))
}
}
fun onTinyActionSaved() {
val state = _uiState.value
retentionAnalytics.track(RetentionEvent.DailyTinyActionSaved(
categoryId = state.answer?.category,
coupleIdHash = state.coupleId?.coupleLoreHash()
))
}
fun showSnackbar(message: String) {
_uiState.update { it.copy(snackbarMessage = message) }
}
@ -330,6 +381,23 @@ class AnswerRevealViewModel @Inject constructor(
_uiState.update { it.copy(snackbarMessage = null) }
}
private fun String.coupleLoreHash(): String {
return try {
java.security.MessageDigest.getInstance("SHA-256")
.digest(toByteArray())
.take(8)
.joinToString("") { "%02x".format(it) }
} catch (e: Exception) { "hash_error" }
}
private fun LocalAnswer.revealSummaryText(): String = when (answerType) {
"written" -> writtenText.orEmpty()
"scale" -> "Chose ${scaleValue ?: "-"}"
"single_choice", "multi_choice", "this_or_that" ->
selectedOptionTexts.ifEmpty { selectedOptionIds }.joinToString()
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "" }
}
private fun effectiveDate(answer: LocalAnswer?): String =
answer?.answerDate?.takeIf { it.isNotBlank() }
?: FirestoreAnswerDataSource.todayLocalDateString()

View File

@ -367,9 +367,9 @@ private fun StreakCard(
modifier: Modifier = Modifier
) {
val copy = when (streakCount) {
0 -> "Start a new streak today"
1 -> "1 day streak"
else -> "$streakCount day streak"
0 -> "Your little ritual is waiting."
1 -> "1 day showing up"
else -> "$streakCount days showing up"
}
val partnerLine = if (streakCount > 0 && !partnerName.isNullOrBlank()) "with $partnerName" else null

View File

@ -350,9 +350,9 @@ private fun PartnerIdentityCard(
)
Text(
text = when (streakCount) {
0 -> "Start a streak together"
1 -> "1 day streak"
else -> "$streakCount day streak"
0 -> "Start your little ritual together"
1 -> "1 day showing up"
else -> "$streakCount days showing up"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant

View File

@ -28,10 +28,14 @@ fun DailyQuestionScreen(
else -> null
}
val mode = state.dailyMode
val title = mode?.title ?: "One question, enough space"
val subtitle = mode?.subtitle ?: "Answer privately first, then choose whether to reveal it or keep the conversation going."
LocalQuestionContent(
state = state,
title = "One question, enough space",
subtitle = "Answer privately first, then choose whether to reveal it or keep the conversation going.",
title = title,
subtitle = subtitle,
primaryRouteLabel = "Discuss",
onPrimaryRoute = { question ->
val coupleId = state.coupleId

View File

@ -2,8 +2,12 @@ package app.closer.ui.questions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.analytics.RetentionAnalytics
import app.closer.analytics.RetentionEvent
import app.closer.core.billing.EntitlementChecker
import app.closer.core.crash.CrashReporter
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.DailyModeResolver
import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question
@ -18,6 +22,7 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -32,7 +37,8 @@ data class LocalQuestionUiState(
val pendingWrittenText: String = "",
val pendingSelectedOptionIds: List<String> = emptyList(),
val pendingScaleValue: Int = 3,
val partnerHasAnswered: Boolean = false
val partnerHasAnswered: Boolean = false,
val dailyMode: DailyModeResolver.DailyMode? = null
)
@HiltViewModel
@ -43,6 +49,8 @@ class DailyQuestionViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val crashReporter: CrashReporter,
private val entitlementChecker: EntitlementChecker,
private val retentionAnalytics: RetentionAnalytics,
private val db: FirebaseFirestore
) : ViewModel() {
@ -65,8 +73,9 @@ class DailyQuestionViewModel @Inject constructor(
viewModelScope.launch {
_uiState.value = LocalQuestionUiState(isLoading = true)
try {
val resolvedMode = DailyModeResolver.resolve()
val today = FirestoreAnswerDataSource.todayLocalDateString()
val (coupleId, question) = loadCoupleAndQuestion(today)
val (coupleId, question) = loadCoupleAndQuestion(today, resolvedMode)
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
val partnerHasAnswered = coupleId?.let {
runCatching { checkPartnerAnswered(it, today) }.getOrDefault(false)
@ -77,11 +86,15 @@ class DailyQuestionViewModel @Inject constructor(
coupleId = coupleId,
dailyQuestionDate = today,
pendingScaleValue = defaultScaleValue(question),
partnerHasAnswered = partnerHasAnswered
partnerHasAnswered = partnerHasAnswered,
dailyMode = resolvedMode
).withLocalAnswer(answer)
if (coupleId != null) startPartnerAnswerObserver(coupleId, today)
question?.let { observeLocalAnswerRevealed(it.id) }
retentionAnalytics.track(RetentionEvent.DailyQuestionViewed(categoryId = question?.category))
retentionAnalytics.track(RetentionEvent.DailyModeResolved(categoryId = resolvedMode.id))
} catch (e: Exception) {
crashReporter.recordException(e)
_uiState.value = LocalQuestionUiState(
@ -124,12 +137,16 @@ class DailyQuestionViewModel @Inject constructor(
*
* For paired users, read the couple's assigned daily question from Firestore
* so both partners see the same prompt. If no assignment exists yet, request
* one from the cloud function and fall back to a local random question while
* waiting.
* one from the cloud function and fall back to a local mode-tagged question.
*
* For unpaired users, fall back to the local random question pool.
* For unpaired users, fall back to a mode-tagged question from the daily_fun_mc pack.
*/
private suspend fun loadCoupleAndQuestion(today: String): Pair<String?, Question?> {
private suspend fun loadCoupleAndQuestion(
today: String,
mode: DailyModeResolver.DailyMode
): Pair<String?, Question?> {
val isPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false)
val couple = authRepository.currentUserId?.let { uid ->
runCatching { coupleRepository.getCoupleForUser(uid) }
.onFailure { crashReporter.recordException(it) }
@ -137,7 +154,8 @@ class DailyQuestionViewModel @Inject constructor(
}
if (couple == null) {
return null to repository.getDailyQuestion()
return null to (repository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: repository.getDailyQuestion())
}
val coupleId = couple.id
@ -146,16 +164,19 @@ class DailyQuestionViewModel @Inject constructor(
}.onFailure { crashReporter.recordException(it) }.getOrNull()
val question = if (assignment != null) {
repository.getQuestionById(assignment.questionId) ?: repository.getDailyQuestion()
repository.getQuestionById(assignment.questionId)
?: repository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: repository.getDailyQuestion()
} else {
// No assignment yet. Request immediate assignment, but keep the app
// usable with a local random question in case the call fails.
// usable with a local mode-tagged question in case the call fails.
runCatching {
firestoreAnswerDataSource.requestDailyQuestionAssignment(coupleId, today)
repository.getQuestionById(
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
?: repository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: repository.getDailyQuestion()
}
@ -195,6 +216,7 @@ class DailyQuestionViewModel @Inject constructor(
val localAnswer = state.toLocalAnswer(question).copy(updatedAt = System.currentTimeMillis())
localAnswerRepository.saveAnswer(localAnswer)
_uiState.update { it.copy(submitted = true) }
retentionAnalytics.track(RetentionEvent.DailyQuestionAnswered(categoryId = question.category))
syncAnswerToFirestore(state.coupleId, state.dailyQuestionDate, question.id, localAnswer)
}
}