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 1b170aee..18266df3 100644 Binary files a/app/src/main/assets/database/app.db and b/app/src/main/assets/database/app.db differ diff --git a/app/src/main/java/app/closer/MainActivity.kt b/app/src/main/java/app/closer/MainActivity.kt index c88fd8cc..d1b65603 100644 --- a/app/src/main/java/app/closer/MainActivity.kt +++ b/app/src/main/java/app/closer/MainActivity.kt @@ -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 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) } }