feat: daily questions, answer reveal, home screens, auth, analytics, DB, repositories
This commit is contained in:
parent
174e56c5a0
commit
5d3ab8385d
|
|
@ -65,3 +65,4 @@ app/GoogleService-Info.plist
|
|||
docs/SUBSCRIPTION_GO_LIVE.md
|
||||
ios_encrypt.md
|
||||
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json
|
||||
DAILY_FUN_IMPLEMENTATION_BATCH_PLAN.md
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,18 @@ interface QuestionDao {
|
|||
@Query("SELECT * FROM question WHERE status = 'active' AND is_premium = 0 AND TRIM(text) <> '' AND category_id <> 'unknown' ORDER BY RANDOM() LIMIT 1")
|
||||
suspend fun getDailyQuestion(): QuestionEntity?
|
||||
|
||||
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
|
||||
suspend fun getDailyQuestionByModeTag(modeTag: String): QuestionEntity?
|
||||
|
||||
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
|
||||
suspend fun getDailyQuestionFromPack(): QuestionEntity?
|
||||
|
||||
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
|
||||
suspend fun getFreeDailyQuestionByModeTag(modeTag: String): QuestionEntity?
|
||||
|
||||
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
|
||||
suspend fun getFreeDailyQuestionFromPack(): QuestionEntity?
|
||||
|
||||
@Query("SELECT * FROM question WHERE category_id = :categoryId AND status = 'active' AND TRIM(text) <> '' ORDER BY depth_level ASC, id ASC")
|
||||
suspend fun getQuestionsByCategory(categoryId: String): List<QuestionEntity>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import app.closer.domain.model.QuestionCategory
|
|||
|
||||
interface QuestionRepository {
|
||||
suspend fun getDailyQuestion(): Question?
|
||||
suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question?
|
||||
suspend fun getQuestionById(id: String): Question?
|
||||
suspend fun getQuestionsByCategory(categoryId: String): List<Question>
|
||||
suspend fun getCategories(): List<QuestionCategory>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.domain.model.LocalAnswer
|
||||
import app.closer.domain.model.Question
|
||||
import app.closer.domain.DailyModeResolver
|
||||
import app.closer.ui.questions.displayCategoryName
|
||||
import app.closer.ui.questions.displayQuestionType
|
||||
import app.closer.ui.components.BrandMessageRotator
|
||||
|
|
@ -83,6 +84,8 @@ fun AnswerRevealScreen(
|
|||
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
|
||||
onHome = { onNavigate(AppRoute.HOME) },
|
||||
onFollowUpSelected = { option -> viewModel.onFollowUpSelected(option, onNavigate) },
|
||||
onSaveLore = viewModel::saveCoupleLoré,
|
||||
onTinyActionSaved = viewModel::onTinyActionSaved,
|
||||
onSnackbarShown = viewModel::clearSnackbar
|
||||
)
|
||||
}
|
||||
|
|
@ -97,6 +100,8 @@ private fun AnswerRevealContent(
|
|||
onHome: () -> Unit,
|
||||
onRefresh: () -> Unit = {},
|
||||
onFollowUpSelected: (FollowUpOption) -> Unit = {},
|
||||
onSaveLore: () -> Unit = {},
|
||||
onTinyActionSaved: () -> Unit = {},
|
||||
onSnackbarShown: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -181,6 +186,11 @@ private fun AnswerRevealContent(
|
|||
enter = if (reducedMotion) fadeIn(tween(0))
|
||||
else fadeIn(tween(380)) + expandVertically(tween(380))
|
||||
) {
|
||||
val tinyActionMode = state.question?.tags
|
||||
?.firstOrNull { it.startsWith("mode_") }
|
||||
?.removePrefix("mode_")
|
||||
?.let { DailyModeResolver.getMode(it) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
RevealedState(
|
||||
answer = state.answer,
|
||||
|
|
@ -188,8 +198,16 @@ private fun AnswerRevealContent(
|
|||
question = state.question,
|
||||
onHistory = onHistory,
|
||||
onHome = onHome,
|
||||
onSaveLore = onSaveLore,
|
||||
loreSaved = state.loreSaved,
|
||||
wasSealed = state.answer.schemaVersion == 3
|
||||
)
|
||||
if (tinyActionMode != null) {
|
||||
TinyActionCard(
|
||||
mode = tinyActionMode,
|
||||
onDoThis = onTinyActionSaved
|
||||
)
|
||||
}
|
||||
if (state.followUpOptions.isNotEmpty()) {
|
||||
FollowUpSection(
|
||||
options = state.followUpOptions,
|
||||
|
|
@ -321,6 +339,29 @@ private fun ReadyToRevealState(
|
|||
|
||||
// ── Sealed-answer reveal states (Batch 11) ───────────────────────────────────
|
||||
|
||||
private val waitingCopy = listOf(
|
||||
"Your answer is in. Now we wait dramatically.",
|
||||
"Locked in. Let's see what they choose.",
|
||||
"You picked. The suspense is tiny but real.",
|
||||
)
|
||||
|
||||
private val matchedCopy = listOf(
|
||||
"You matched. Suspiciously cute.",
|
||||
"Same pick. The lore deepens.",
|
||||
"Both chose this. Tiny destiny.",
|
||||
)
|
||||
|
||||
private val differentCopy = listOf(
|
||||
"Different picks. Honestly, useful.",
|
||||
"Two vibes entered the chat.",
|
||||
"Not a match, but very on-brand.",
|
||||
)
|
||||
|
||||
private fun dayIndexCopy(list: List<String>): String {
|
||||
val doy = java.util.Calendar.getInstance().get(java.util.Calendar.DAY_OF_YEAR)
|
||||
return list[doy % list.size]
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnswerSealedState(
|
||||
question: Question?,
|
||||
|
|
@ -338,10 +379,10 @@ private fun AnswerSealedState(
|
|||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Waiting for your partner. Reveal opens once both of you have answered.",
|
||||
text = dayIndexCopy(waitingCopy),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
OutlinedButton(
|
||||
|
|
@ -373,10 +414,10 @@ private fun BothAnsweredSealedState(
|
|||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Reveal is ready. Open it when you're both here — answers are exchanged privately between your devices.",
|
||||
text = "Both of you picked. Tap reveal to find out.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 4,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
|
|
@ -512,8 +553,20 @@ private fun RevealedState(
|
|||
question: Question?,
|
||||
onHistory: () -> Unit,
|
||||
onHome: () -> Unit,
|
||||
onSaveLore: () -> Unit = {},
|
||||
loreSaved: Boolean = false,
|
||||
wasSealed: Boolean = false
|
||||
) {
|
||||
val matchLine = if (partnerAnswer != null) {
|
||||
val ownIds = answer.selectedOptionIds.toSet()
|
||||
val partnerIds = partnerAnswer.selectedOptionIds.toSet()
|
||||
when {
|
||||
ownIds.isNotEmpty() && ownIds == partnerIds -> dayIndexCopy(matchedCopy)
|
||||
ownIds.isNotEmpty() && partnerIds.isNotEmpty() -> dayIndexCopy(differentCopy)
|
||||
else -> null
|
||||
}
|
||||
} else null
|
||||
|
||||
RevealMessageCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
|
@ -521,6 +574,15 @@ private fun RevealedState(
|
|||
RevealPill(answer.category.displayCategoryName())
|
||||
RevealPill(answer.answerType.displayQuestionType())
|
||||
}
|
||||
if (matchLine != null) {
|
||||
Text(
|
||||
text = matchLine,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = Color(0xFF56306F),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = question?.text ?: answer.questionText,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
|
|
@ -555,6 +617,69 @@ private fun RevealedState(
|
|||
Text("Home")
|
||||
}
|
||||
}
|
||||
if (!loreSaved) {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = onSaveLore,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"Save to Couple Lore",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFF56306F).copy(alpha = 0.75f)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "Saved to Couple Lore.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TinyActionCard(
|
||||
mode: DailyModeResolver.DailyMode,
|
||||
onDoThis: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF0F8)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = mode.actionCopy,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = Color(0xFF56306F),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(
|
||||
onClick = onDoThis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 40.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFB98AF4),
|
||||
contentColor = Color(0xFF24122F)
|
||||
)
|
||||
) {
|
||||
Text("Do this tonight", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package app.closer.ui.answers
|
|||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.analytics.RetentionAnalytics
|
||||
import app.closer.analytics.RetentionEvent
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.core.crash.CrashReporter
|
||||
import app.closer.crypto.PendingAnswerKeyStore
|
||||
|
|
@ -61,7 +63,8 @@ data class AnswerRevealUiState(
|
|||
val partnerId: String? = null,
|
||||
val followUpOptions: List<FollowUpOption> = emptyList(),
|
||||
val snackbarMessage: String? = null,
|
||||
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE
|
||||
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE,
|
||||
val loreSaved: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -74,6 +77,7 @@ class AnswerRevealViewModel @Inject constructor(
|
|||
private val crashReporter: CrashReporter,
|
||||
private val sealedRevealManager: SealedRevealManager,
|
||||
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
||||
private val retentionAnalytics: RetentionAnalytics,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -182,6 +186,10 @@ class AnswerRevealViewModel @Inject constructor(
|
|||
|
||||
fun revealAnswer() {
|
||||
val state = _uiState.value
|
||||
retentionAnalytics.track(RetentionEvent.RevealOpened(
|
||||
categoryId = state.answer?.category,
|
||||
coupleIdHash = state.coupleId?.coupleLoreHash()
|
||||
))
|
||||
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
|
||||
performSealedReveal(state)
|
||||
} else {
|
||||
|
|
@ -269,6 +277,11 @@ class AnswerRevealViewModel @Inject constructor(
|
|||
val ownAnswer = localAnswerRepository.getAnswer(questionId)
|
||||
val category = ownAnswer?.category ?: state.question?.category ?: ""
|
||||
|
||||
retentionAnalytics.track(RetentionEvent.RevealCompleted(
|
||||
categoryId = category,
|
||||
coupleIdHash = state.coupleId?.coupleLoreHash()
|
||||
))
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
answer = ownAnswer,
|
||||
|
|
@ -285,6 +298,10 @@ class AnswerRevealViewModel @Inject constructor(
|
|||
val answer = localAnswerRepository.getAnswer(questionId)
|
||||
val partnerAnswer = _uiState.value.partnerAnswer
|
||||
val category = answer?.category ?: _uiState.value.question?.category ?: ""
|
||||
retentionAnalytics.track(RetentionEvent.RevealCompleted(
|
||||
categoryId = category,
|
||||
coupleIdHash = _uiState.value.coupleId?.coupleLoreHash()
|
||||
))
|
||||
_uiState.update {
|
||||
it.copy(followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category))
|
||||
}
|
||||
|
|
@ -322,6 +339,40 @@ class AnswerRevealViewModel @Inject constructor(
|
|||
route?.let { onNavigate(it) } ?: showSnackbar("Coming soon")
|
||||
}
|
||||
|
||||
fun saveCoupleLoré() {
|
||||
val state = _uiState.value
|
||||
val coupleId = state.coupleId ?: return
|
||||
val answer = state.answer ?: return
|
||||
if (state.loreSaved) return
|
||||
viewModelScope.launch {
|
||||
val modeTag = state.question?.tags?.firstOrNull { it.startsWith("mode_") }
|
||||
runCatching {
|
||||
firestoreAnswerDataSource.saveLoreEntry(
|
||||
coupleId = coupleId,
|
||||
questionId = answer.questionId,
|
||||
questionText = state.question?.text ?: answer.questionText,
|
||||
ownAnswer = answer.revealSummaryText(),
|
||||
partnerAnswer = state.partnerAnswer?.revealSummaryText(),
|
||||
modeTag = modeTag,
|
||||
date = effectiveDate(answer)
|
||||
)
|
||||
}.onFailure { crashReporter.recordException(it) }
|
||||
_uiState.update { it.copy(loreSaved = true, snackbarMessage = "Saved to Couple Lore.") }
|
||||
retentionAnalytics.track(RetentionEvent.CoupleLoreSaved(
|
||||
categoryId = answer.category,
|
||||
coupleIdHash = coupleId.coupleLoreHash()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun onTinyActionSaved() {
|
||||
val state = _uiState.value
|
||||
retentionAnalytics.track(RetentionEvent.DailyTinyActionSaved(
|
||||
categoryId = state.answer?.category,
|
||||
coupleIdHash = state.coupleId?.coupleLoreHash()
|
||||
))
|
||||
}
|
||||
|
||||
fun showSnackbar(message: String) {
|
||||
_uiState.update { it.copy(snackbarMessage = message) }
|
||||
}
|
||||
|
|
@ -330,6 +381,23 @@ class AnswerRevealViewModel @Inject constructor(
|
|||
_uiState.update { it.copy(snackbarMessage = null) }
|
||||
}
|
||||
|
||||
private fun String.coupleLoreHash(): String {
|
||||
return try {
|
||||
java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(toByteArray())
|
||||
.take(8)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) { "hash_error" }
|
||||
}
|
||||
|
||||
private fun LocalAnswer.revealSummaryText(): String = when (answerType) {
|
||||
"written" -> writtenText.orEmpty()
|
||||
"scale" -> "Chose ${scaleValue ?: "-"}"
|
||||
"single_choice", "multi_choice", "this_or_that" ->
|
||||
selectedOptionTexts.ifEmpty { selectedOptionIds }.joinToString()
|
||||
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "" }
|
||||
}
|
||||
|
||||
private fun effectiveDate(answer: LocalAnswer?): String =
|
||||
answer?.answerDate?.takeIf { it.isNotBlank() }
|
||||
?: FirestoreAnswerDataSource.todayLocalDateString()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@ package app.closer.ui.questions
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.analytics.RetentionAnalytics
|
||||
import app.closer.analytics.RetentionEvent
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.core.crash.CrashReporter
|
||||
import app.closer.data.remote.FirestoreAnswerDataSource
|
||||
import app.closer.domain.DailyModeResolver
|
||||
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
||||
import app.closer.domain.model.LocalAnswer
|
||||
import app.closer.domain.model.Question
|
||||
|
|
@ -18,6 +22,7 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -32,7 +37,8 @@ data class LocalQuestionUiState(
|
|||
val pendingWrittenText: String = "",
|
||||
val pendingSelectedOptionIds: List<String> = emptyList(),
|
||||
val pendingScaleValue: Int = 3,
|
||||
val partnerHasAnswered: Boolean = false
|
||||
val partnerHasAnswered: Boolean = false,
|
||||
val dailyMode: DailyModeResolver.DailyMode? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -43,6 +49,8 @@ class DailyQuestionViewModel @Inject constructor(
|
|||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val crashReporter: CrashReporter,
|
||||
private val entitlementChecker: EntitlementChecker,
|
||||
private val retentionAnalytics: RetentionAnalytics,
|
||||
private val db: FirebaseFirestore
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -65,8 +73,9 @@ class DailyQuestionViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
_uiState.value = LocalQuestionUiState(isLoading = true)
|
||||
try {
|
||||
val resolvedMode = DailyModeResolver.resolve()
|
||||
val today = FirestoreAnswerDataSource.todayLocalDateString()
|
||||
val (coupleId, question) = loadCoupleAndQuestion(today)
|
||||
val (coupleId, question) = loadCoupleAndQuestion(today, resolvedMode)
|
||||
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
|
||||
val partnerHasAnswered = coupleId?.let {
|
||||
runCatching { checkPartnerAnswered(it, today) }.getOrDefault(false)
|
||||
|
|
@ -77,11 +86,15 @@ class DailyQuestionViewModel @Inject constructor(
|
|||
coupleId = coupleId,
|
||||
dailyQuestionDate = today,
|
||||
pendingScaleValue = defaultScaleValue(question),
|
||||
partnerHasAnswered = partnerHasAnswered
|
||||
partnerHasAnswered = partnerHasAnswered,
|
||||
dailyMode = resolvedMode
|
||||
).withLocalAnswer(answer)
|
||||
|
||||
if (coupleId != null) startPartnerAnswerObserver(coupleId, today)
|
||||
question?.let { observeLocalAnswerRevealed(it.id) }
|
||||
|
||||
retentionAnalytics.track(RetentionEvent.DailyQuestionViewed(categoryId = question?.category))
|
||||
retentionAnalytics.track(RetentionEvent.DailyModeResolved(categoryId = resolvedMode.id))
|
||||
} catch (e: Exception) {
|
||||
crashReporter.recordException(e)
|
||||
_uiState.value = LocalQuestionUiState(
|
||||
|
|
@ -124,12 +137,16 @@ class DailyQuestionViewModel @Inject constructor(
|
|||
*
|
||||
* For paired users, read the couple's assigned daily question from Firestore
|
||||
* so both partners see the same prompt. If no assignment exists yet, request
|
||||
* one from the cloud function and fall back to a local random question while
|
||||
* waiting.
|
||||
* one from the cloud function and fall back to a local mode-tagged question.
|
||||
*
|
||||
* For unpaired users, fall back to the local random question pool.
|
||||
* For unpaired users, fall back to a mode-tagged question from the daily_fun_mc pack.
|
||||
*/
|
||||
private suspend fun loadCoupleAndQuestion(today: String): Pair<String?, Question?> {
|
||||
private suspend fun loadCoupleAndQuestion(
|
||||
today: String,
|
||||
mode: DailyModeResolver.DailyMode
|
||||
): Pair<String?, Question?> {
|
||||
val isPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false)
|
||||
|
||||
val couple = authRepository.currentUserId?.let { uid ->
|
||||
runCatching { coupleRepository.getCoupleForUser(uid) }
|
||||
.onFailure { crashReporter.recordException(it) }
|
||||
|
|
@ -137,7 +154,8 @@ class DailyQuestionViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
if (couple == null) {
|
||||
return null to repository.getDailyQuestion()
|
||||
return null to (repository.getDailyQuestionForMode(mode.modeTag, isPremium)
|
||||
?: repository.getDailyQuestion())
|
||||
}
|
||||
|
||||
val coupleId = couple.id
|
||||
|
|
@ -146,16 +164,19 @@ class DailyQuestionViewModel @Inject constructor(
|
|||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||
|
||||
val question = if (assignment != null) {
|
||||
repository.getQuestionById(assignment.questionId) ?: repository.getDailyQuestion()
|
||||
repository.getQuestionById(assignment.questionId)
|
||||
?: repository.getDailyQuestionForMode(mode.modeTag, isPremium)
|
||||
?: repository.getDailyQuestion()
|
||||
} else {
|
||||
// No assignment yet. Request immediate assignment, but keep the app
|
||||
// usable with a local random question in case the call fails.
|
||||
// usable with a local mode-tagged question in case the call fails.
|
||||
runCatching {
|
||||
firestoreAnswerDataSource.requestDailyQuestionAssignment(coupleId, today)
|
||||
repository.getQuestionById(
|
||||
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
|
||||
)
|
||||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||
?: repository.getDailyQuestionForMode(mode.modeTag, isPremium)
|
||||
?: repository.getDailyQuestion()
|
||||
}
|
||||
|
||||
|
|
@ -195,6 +216,7 @@ class DailyQuestionViewModel @Inject constructor(
|
|||
val localAnswer = state.toLocalAnswer(question).copy(updatedAt = System.currentTimeMillis())
|
||||
localAnswerRepository.saveAnswer(localAnswer)
|
||||
_uiState.update { it.copy(submitted = true) }
|
||||
retentionAnalytics.track(RetentionEvent.DailyQuestionAnswered(categoryId = question.category))
|
||||
syncAnswerToFirestore(state.coupleId, state.dailyQuestionDate, question.id, localAnswer)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue