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 324b051834
commit 51f33d93a6
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 docs/SUBSCRIPTION_GO_LIVE.md
ios_encrypt.md ios_encrypt.md
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json 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.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
@ -22,13 +23,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import app.closer.BuildConfig
import app.closer.core.navigation.AppNavigation import app.closer.core.navigation.AppNavigation
import app.closer.domain.repository.AppSettings import app.closer.domain.repository.AppSettings
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.SettingsRepository import app.closer.domain.repository.SettingsRepository
import app.closer.domain.repository.ThemeMode import app.closer.domain.repository.ThemeMode
import app.closer.ui.theme.CloserTheme import app.closer.ui.theme.CloserTheme
import com.google.firebase.auth.FirebaseAuth
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -38,6 +42,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (BuildConfig.DEBUG) attemptDebugAutoLogin()
setContent { setContent {
val settings by settingsRepository.settings.collectAsState(initial = AppSettings()) val settings by settingsRepository.settings.collectAsState(initial = AppSettings())
val systemInDarkTheme = isSystemInDarkTheme() val systemInDarkTheme = isSystemInDarkTheme()
@ -80,6 +85,18 @@ class MainActivity : AppCompatActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(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 @Composable

View File

@ -26,6 +26,10 @@ enum class RetentionEventType {
MEMORY_CAPSULE_UNLOCKED, MEMORY_CAPSULE_UNLOCKED,
PUSH_NOTIFICATION_SENT, PUSH_NOTIFICATION_SENT,
PUSH_NOTIFICATION_OPENED, 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, coupleIdHash = coupleIdHash,
timestamp = timestamp, 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") @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? 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") @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> suspend fun getQuestionsByCategory(categoryId: String): List<QuestionEntity>

View File

@ -1,5 +1,6 @@
package app.closer.data.remote package app.closer.data.remote
import app.closer.BuildConfig
import app.closer.domain.model.AuthState import app.closer.domain.model.AuthState
import app.closer.domain.model.GoogleSignInResult import app.closer.domain.model.GoogleSignInResult
import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.EmailAuthProvider
@ -17,7 +18,9 @@ import kotlin.coroutines.resumeWithException
@Singleton @Singleton
class FirebaseAuthDataSource @Inject constructor() { 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 currentUserId: String? get() = auth.currentUser?.uid
val currentUserEmail: String? get() = auth.currentUser?.email 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( data class DailyQuestionAssignment(
val questionId: String, val questionId: String,
val date: String, val date: String,

View File

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

View File

@ -19,6 +19,18 @@ class RoomQuestionRepository @Inject constructor(
return questionDao.getDailyQuestion()?.toQuestion() 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? { override suspend fun getQuestionById(id: String): Question? {
return questionDao.getQuestionById(id)?.toQuestion() 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 { interface QuestionRepository {
suspend fun getDailyQuestion(): Question? suspend fun getDailyQuestion(): Question?
suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question?
suspend fun getQuestionById(id: String): Question? suspend fun getQuestionById(id: String): Question?
suspend fun getQuestionsByCategory(categoryId: String): List<Question> suspend fun getQuestionsByCategory(categoryId: String): List<Question>
suspend fun getCategories(): List<QuestionCategory> 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.core.navigation.AppRoute
import app.closer.domain.model.LocalAnswer import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.DailyModeResolver
import app.closer.ui.questions.displayCategoryName import app.closer.ui.questions.displayCategoryName
import app.closer.ui.questions.displayQuestionType import app.closer.ui.questions.displayQuestionType
import app.closer.ui.components.BrandMessageRotator import app.closer.ui.components.BrandMessageRotator
@ -83,6 +84,8 @@ fun AnswerRevealScreen(
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) }, onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
onHome = { onNavigate(AppRoute.HOME) }, onHome = { onNavigate(AppRoute.HOME) },
onFollowUpSelected = { option -> viewModel.onFollowUpSelected(option, onNavigate) }, onFollowUpSelected = { option -> viewModel.onFollowUpSelected(option, onNavigate) },
onSaveLore = viewModel::saveCoupleLoré,
onTinyActionSaved = viewModel::onTinyActionSaved,
onSnackbarShown = viewModel::clearSnackbar onSnackbarShown = viewModel::clearSnackbar
) )
} }
@ -97,6 +100,8 @@ private fun AnswerRevealContent(
onHome: () -> Unit, onHome: () -> Unit,
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onFollowUpSelected: (FollowUpOption) -> Unit = {}, onFollowUpSelected: (FollowUpOption) -> Unit = {},
onSaveLore: () -> Unit = {},
onTinyActionSaved: () -> Unit = {},
onSnackbarShown: () -> Unit = {} onSnackbarShown: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -181,6 +186,11 @@ private fun AnswerRevealContent(
enter = if (reducedMotion) fadeIn(tween(0)) enter = if (reducedMotion) fadeIn(tween(0))
else fadeIn(tween(380)) + expandVertically(tween(380)) 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)) { Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
RevealedState( RevealedState(
answer = state.answer, answer = state.answer,
@ -188,8 +198,16 @@ private fun AnswerRevealContent(
question = state.question, question = state.question,
onHistory = onHistory, onHistory = onHistory,
onHome = onHome, onHome = onHome,
onSaveLore = onSaveLore,
loreSaved = state.loreSaved,
wasSealed = state.answer.schemaVersion == 3 wasSealed = state.answer.schemaVersion == 3
) )
if (tinyActionMode != null) {
TinyActionCard(
mode = tinyActionMode,
onDoThis = onTinyActionSaved
)
}
if (state.followUpOptions.isNotEmpty()) { if (state.followUpOptions.isNotEmpty()) {
FollowUpSection( FollowUpSection(
options = state.followUpOptions, options = state.followUpOptions,
@ -321,6 +339,29 @@ private fun ReadyToRevealState(
// ── Sealed-answer reveal states (Batch 11) ─────────────────────────────────── // ── 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 @Composable
private fun AnswerSealedState( private fun AnswerSealedState(
question: Question?, question: Question?,
@ -338,10 +379,10 @@ private fun AnswerSealedState(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = "Waiting for your partner. Reveal opens once both of you have answered.", text = dayIndexCopy(waitingCopy),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
OutlinedButton( OutlinedButton(
@ -373,10 +414,10 @@ private fun BothAnsweredSealedState(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@ -512,8 +553,20 @@ private fun RevealedState(
question: Question?, question: Question?,
onHistory: () -> Unit, onHistory: () -> Unit,
onHome: () -> Unit, onHome: () -> Unit,
onSaveLore: () -> Unit = {},
loreSaved: Boolean = false,
wasSealed: 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 { RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@ -521,6 +574,15 @@ private fun RevealedState(
RevealPill(answer.category.displayCategoryName()) RevealPill(answer.category.displayCategoryName())
RevealPill(answer.answerType.displayQuestionType()) 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(
text = question?.text ?: answer.questionText, text = question?.text ?: answer.questionText,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
@ -555,6 +617,69 @@ private fun RevealedState(
Text("Home") 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.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.analytics.RetentionAnalytics
import app.closer.analytics.RetentionEvent
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.core.crash.CrashReporter import app.closer.core.crash.CrashReporter
import app.closer.crypto.PendingAnswerKeyStore import app.closer.crypto.PendingAnswerKeyStore
@ -61,7 +63,8 @@ data class AnswerRevealUiState(
val partnerId: String? = null, val partnerId: String? = null,
val followUpOptions: List<FollowUpOption> = emptyList(), val followUpOptions: List<FollowUpOption> = emptyList(),
val snackbarMessage: String? = null, val snackbarMessage: String? = null,
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE,
val loreSaved: Boolean = false
) )
@HiltViewModel @HiltViewModel
@ -74,6 +77,7 @@ class AnswerRevealViewModel @Inject constructor(
private val crashReporter: CrashReporter, private val crashReporter: CrashReporter,
private val sealedRevealManager: SealedRevealManager, private val sealedRevealManager: SealedRevealManager,
private val pendingAnswerKeyStore: PendingAnswerKeyStore, private val pendingAnswerKeyStore: PendingAnswerKeyStore,
private val retentionAnalytics: RetentionAnalytics,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
@ -182,6 +186,10 @@ class AnswerRevealViewModel @Inject constructor(
fun revealAnswer() { fun revealAnswer() {
val state = _uiState.value val state = _uiState.value
retentionAnalytics.track(RetentionEvent.RevealOpened(
categoryId = state.answer?.category,
coupleIdHash = state.coupleId?.coupleLoreHash()
))
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) { if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
performSealedReveal(state) performSealedReveal(state)
} else { } else {
@ -269,6 +277,11 @@ class AnswerRevealViewModel @Inject constructor(
val ownAnswer = localAnswerRepository.getAnswer(questionId) val ownAnswer = localAnswerRepository.getAnswer(questionId)
val category = ownAnswer?.category ?: state.question?.category ?: "" val category = ownAnswer?.category ?: state.question?.category ?: ""
retentionAnalytics.track(RetentionEvent.RevealCompleted(
categoryId = category,
coupleIdHash = state.coupleId?.coupleLoreHash()
))
_uiState.update { _uiState.update {
it.copy( it.copy(
answer = ownAnswer, answer = ownAnswer,
@ -285,6 +298,10 @@ class AnswerRevealViewModel @Inject constructor(
val answer = localAnswerRepository.getAnswer(questionId) val answer = localAnswerRepository.getAnswer(questionId)
val partnerAnswer = _uiState.value.partnerAnswer val partnerAnswer = _uiState.value.partnerAnswer
val category = answer?.category ?: _uiState.value.question?.category ?: "" val category = answer?.category ?: _uiState.value.question?.category ?: ""
retentionAnalytics.track(RetentionEvent.RevealCompleted(
categoryId = category,
coupleIdHash = _uiState.value.coupleId?.coupleLoreHash()
))
_uiState.update { _uiState.update {
it.copy(followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)) it.copy(followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category))
} }
@ -322,6 +339,40 @@ class AnswerRevealViewModel @Inject constructor(
route?.let { onNavigate(it) } ?: showSnackbar("Coming soon") 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) { fun showSnackbar(message: String) {
_uiState.update { it.copy(snackbarMessage = message) } _uiState.update { it.copy(snackbarMessage = message) }
} }
@ -330,6 +381,23 @@ class AnswerRevealViewModel @Inject constructor(
_uiState.update { it.copy(snackbarMessage = null) } _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 = private fun effectiveDate(answer: LocalAnswer?): String =
answer?.answerDate?.takeIf { it.isNotBlank() } answer?.answerDate?.takeIf { it.isNotBlank() }
?: FirestoreAnswerDataSource.todayLocalDateString() ?: FirestoreAnswerDataSource.todayLocalDateString()

View File

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

View File

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

View File

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

View File

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