From 5d3ab8385d8e262027dc3ab8afafb082f06a3792 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 22 Jun 2026 17:45:51 -0500 Subject: [PATCH] feat: daily questions, answer reveal, home screens, auth, analytics, DB, repositories --- .gitignore | 1 + app/src/main/assets/database/app.db | Bin 1880064 -> 2146304 bytes app/src/main/java/app/closer/MainActivity.kt | 17 +++ .../app/closer/analytics/RetentionEvent.kt | 52 +++++++ .../java/app/closer/data/local/QuestionDao.kt | 12 ++ .../data/remote/FirebaseAuthDataSource.kt | 5 +- .../data/remote/FirestoreAnswerDataSource.kt | 31 ++++ .../data/repository/FakeQuestionRepository.kt | 1 + .../data/repository/RoomQuestionRepository.kt | 12 ++ .../app/closer/domain/DailyModeResolver.kt | 119 ++++++++++++++++ .../domain/repository/QuestionRepository.kt | 1 + .../closer/ui/answers/AnswerRevealScreen.kt | 133 +++++++++++++++++- .../ui/answers/AnswerRevealViewModel.kt | 70 ++++++++- .../java/app/closer/ui/home/HomeScreen.kt | 6 +- .../app/closer/ui/home/PartnerHomeScreen.kt | 6 +- .../ui/questions/DailyQuestionScreen.kt | 8 +- .../ui/questions/DailyQuestionViewModel.kt | 42 ++++-- 17 files changed, 492 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/app/closer/domain/DailyModeResolver.kt diff --git a/.gitignore b/.gitignore index e6bdf9c4..547d3744 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/src/main/assets/database/app.db b/app/src/main/assets/database/app.db index 1b170aee90079374be2b91c0d46adf40a4e91795..18266df3cb96a2d057ea6bb7ae23ebaa4127bc5b 100644 GIT binary patch delta 204093 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 Log.e("CloserDebug", "Debug sign-in failed", e) } + } } @Composable diff --git a/app/src/main/java/app/closer/analytics/RetentionEvent.kt b/app/src/main/java/app/closer/analytics/RetentionEvent.kt index b2a33744..0259bcfc 100644 --- a/app/src/main/java/app/closer/analytics/RetentionEvent.kt +++ b/app/src/main/java/app/closer/analytics/RetentionEvent.kt @@ -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, + ) } diff --git a/app/src/main/java/app/closer/data/local/QuestionDao.kt b/app/src/main/java/app/closer/data/local/QuestionDao.kt index 9fddb037..f750846b 100644 --- a/app/src/main/java/app/closer/data/local/QuestionDao.kt +++ b/app/src/main/java/app/closer/data/local/QuestionDao.kt @@ -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 diff --git a/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt b/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt index 235bb2ba..53e097d2 100644 --- a/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt @@ -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 diff --git a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt index f7347789..7d333fb0 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt @@ -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, diff --git a/app/src/main/java/app/closer/data/repository/FakeQuestionRepository.kt b/app/src/main/java/app/closer/data/repository/FakeQuestionRepository.kt index a1816a21..9fd69c66 100644 --- a/app/src/main/java/app/closer/data/repository/FakeQuestionRepository.kt +++ b/app/src/main/java/app/closer/data/repository/FakeQuestionRepository.kt @@ -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 diff --git a/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt b/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt index eba00618..f276f819 100644 --- a/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt +++ b/app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt @@ -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() } diff --git a/app/src/main/java/app/closer/domain/DailyModeResolver.kt b/app/src/main/java/app/closer/domain/DailyModeResolver.kt new file mode 100644 index 00000000..b9f93356 --- /dev/null +++ b/app/src/main/java/app/closer/domain/DailyModeResolver.kt @@ -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 = MODES.values +} diff --git a/app/src/main/java/app/closer/domain/repository/QuestionRepository.kt b/app/src/main/java/app/closer/domain/repository/QuestionRepository.kt index 4ba833e8..9971fcd0 100644 --- a/app/src/main/java/app/closer/domain/repository/QuestionRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/QuestionRepository.kt @@ -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 suspend fun getCategories(): List diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt index e5288a0b..c67220a6 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt @@ -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 { + 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) + } + } } } } diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt index a839aea8..45df19c4 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt @@ -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 = 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() diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 94f64eab..def42e2a 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt b/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt index 118f8a76..281439c4 100644 --- a/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/questions/DailyQuestionScreen.kt b/app/src/main/java/app/closer/ui/questions/DailyQuestionScreen.kt index 19cc0c2c..32f00b16 100644 --- a/app/src/main/java/app/closer/ui/questions/DailyQuestionScreen.kt +++ b/app/src/main/java/app/closer/ui/questions/DailyQuestionScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt index d98639fd..33ac9d41 100644 --- a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt @@ -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 = 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 { + private suspend fun loadCoupleAndQuestion( + today: String, + mode: DailyModeResolver.DailyMode + ): Pair { + 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) } }