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
|
docs/SUBSCRIPTION_GO_LIVE.md
|
||||||
ios_encrypt.md
|
ios_encrypt.md
|
||||||
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json
|
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json
|
||||||
|
DAILY_FUN_IMPLEMENTATION_BATCH_PLAN.md
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -2,6 +2,7 @@ package app.closer
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
|
|
@ -22,13 +23,16 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
import app.closer.BuildConfig
|
||||||
import app.closer.core.navigation.AppNavigation
|
import app.closer.core.navigation.AppNavigation
|
||||||
import app.closer.domain.repository.AppSettings
|
import app.closer.domain.repository.AppSettings
|
||||||
import app.closer.domain.repository.AuthRepository
|
import app.closer.domain.repository.AuthRepository
|
||||||
import app.closer.domain.repository.SettingsRepository
|
import app.closer.domain.repository.SettingsRepository
|
||||||
import app.closer.domain.repository.ThemeMode
|
import app.closer.domain.repository.ThemeMode
|
||||||
import app.closer.ui.theme.CloserTheme
|
import app.closer.ui.theme.CloserTheme
|
||||||
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
|
@ -38,6 +42,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
if (BuildConfig.DEBUG) attemptDebugAutoLogin()
|
||||||
setContent {
|
setContent {
|
||||||
val settings by settingsRepository.settings.collectAsState(initial = AppSettings())
|
val settings by settingsRepository.settings.collectAsState(initial = AppSettings())
|
||||||
val systemInDarkTheme = isSystemInDarkTheme()
|
val systemInDarkTheme = isSystemInDarkTheme()
|
||||||
|
|
@ -80,6 +85,18 @@ class MainActivity : AppCompatActivity() {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun attemptDebugAutoLogin() {
|
||||||
|
val tokenFile = File(filesDir, "closer_debug_token.txt")
|
||||||
|
if (!tokenFile.exists()) return
|
||||||
|
val token = tokenFile.readText().trim()
|
||||||
|
tokenFile.delete()
|
||||||
|
if (token.isBlank()) return
|
||||||
|
Log.d("CloserDebug", "Signing in with debug custom token")
|
||||||
|
FirebaseAuth.getInstance().signInWithCustomToken(token)
|
||||||
|
.addOnSuccessListener { Log.d("CloserDebug", "Debug sign-in succeeded") }
|
||||||
|
.addOnFailureListener { e -> Log.e("CloserDebug", "Debug sign-in failed", e) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ enum class RetentionEventType {
|
||||||
MEMORY_CAPSULE_UNLOCKED,
|
MEMORY_CAPSULE_UNLOCKED,
|
||||||
PUSH_NOTIFICATION_SENT,
|
PUSH_NOTIFICATION_SENT,
|
||||||
PUSH_NOTIFICATION_OPENED,
|
PUSH_NOTIFICATION_OPENED,
|
||||||
|
DAILY_MODE_RESOLVED,
|
||||||
|
DAILY_TINY_ACTION_VIEWED,
|
||||||
|
DAILY_TINY_ACTION_SAVED,
|
||||||
|
COUPLE_LORE_SAVED,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -274,4 +278,52 @@ sealed class RetentionEvent(
|
||||||
coupleIdHash = coupleIdHash,
|
coupleIdHash = coupleIdHash,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class DailyModeResolved(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "daily_question",
|
||||||
|
eventType = RetentionEventType.DAILY_MODE_RESOLVED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DailyTinyActionViewed(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "daily_question",
|
||||||
|
eventType = RetentionEventType.DAILY_TINY_ACTION_VIEWED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DailyTinyActionSaved(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "daily_question",
|
||||||
|
eventType = RetentionEventType.DAILY_TINY_ACTION_SAVED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CoupleLoreSaved(
|
||||||
|
override val categoryId: String? = null,
|
||||||
|
override val coupleIdHash: String? = null,
|
||||||
|
override val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) : RetentionEvent(
|
||||||
|
featureName = "couple_lore",
|
||||||
|
eventType = RetentionEventType.COUPLE_LORE_SAVED,
|
||||||
|
categoryId = categoryId,
|
||||||
|
coupleIdHash = coupleIdHash,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,18 @@ interface QuestionDao {
|
||||||
@Query("SELECT * FROM question WHERE status = 'active' AND is_premium = 0 AND TRIM(text) <> '' AND category_id <> 'unknown' ORDER BY RANDOM() LIMIT 1")
|
@Query("SELECT * FROM question WHERE status = 'active' AND is_premium = 0 AND TRIM(text) <> '' AND category_id <> 'unknown' ORDER BY RANDOM() LIMIT 1")
|
||||||
suspend fun getDailyQuestion(): QuestionEntity?
|
suspend fun getDailyQuestion(): QuestionEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
|
||||||
|
suspend fun getDailyQuestionByModeTag(modeTag: String): QuestionEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
|
||||||
|
suspend fun getDailyQuestionFromPack(): QuestionEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%' || :modeTag || '%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
|
||||||
|
suspend fun getFreeDailyQuestionByModeTag(modeTag: String): QuestionEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM question WHERE category_id = 'daily_fun_mc' AND status = 'active' AND is_premium = 0 AND tags LIKE '%quick_answer%' AND TRIM(text) <> '' ORDER BY RANDOM() LIMIT 1")
|
||||||
|
suspend fun getFreeDailyQuestionFromPack(): QuestionEntity?
|
||||||
|
|
||||||
@Query("SELECT * FROM question WHERE category_id = :categoryId AND status = 'active' AND TRIM(text) <> '' ORDER BY depth_level ASC, id ASC")
|
@Query("SELECT * FROM question WHERE category_id = :categoryId AND status = 'active' AND TRIM(text) <> '' ORDER BY depth_level ASC, id ASC")
|
||||||
suspend fun getQuestionsByCategory(categoryId: String): List<QuestionEntity>
|
suspend fun getQuestionsByCategory(categoryId: String): List<QuestionEntity>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.BuildConfig
|
||||||
import app.closer.domain.model.AuthState
|
import app.closer.domain.model.AuthState
|
||||||
import app.closer.domain.model.GoogleSignInResult
|
import app.closer.domain.model.GoogleSignInResult
|
||||||
import com.google.firebase.auth.EmailAuthProvider
|
import com.google.firebase.auth.EmailAuthProvider
|
||||||
|
|
@ -17,7 +18,9 @@ import kotlin.coroutines.resumeWithException
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirebaseAuthDataSource @Inject constructor() {
|
class FirebaseAuthDataSource @Inject constructor() {
|
||||||
|
|
||||||
private val auth = FirebaseAuth.getInstance()
|
private val auth = FirebaseAuth.getInstance().also {
|
||||||
|
if (BuildConfig.DEBUG) it.firebaseAuthSettings.setAppVerificationDisabledForTesting(true)
|
||||||
|
}
|
||||||
|
|
||||||
val currentUserId: String? get() = auth.currentUser?.uid
|
val currentUserId: String? get() = auth.currentUser?.uid
|
||||||
val currentUserEmail: String? get() = auth.currentUser?.email
|
val currentUserEmail: String? get() = auth.currentUser?.email
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,37 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a "Couple Lore" entry to Firestore.
|
||||||
|
* Path: couples/{coupleId}/lore/{questionId}
|
||||||
|
*/
|
||||||
|
suspend fun saveLoreEntry(
|
||||||
|
coupleId: String,
|
||||||
|
questionId: String,
|
||||||
|
questionText: String,
|
||||||
|
ownAnswer: String,
|
||||||
|
partnerAnswer: String?,
|
||||||
|
modeTag: String?,
|
||||||
|
date: String
|
||||||
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
|
val doc = mapOf(
|
||||||
|
"questionId" to questionId,
|
||||||
|
"questionText" to questionText,
|
||||||
|
"ownAnswer" to ownAnswer,
|
||||||
|
"partnerAnswer" to partnerAnswer,
|
||||||
|
"modeTag" to modeTag,
|
||||||
|
"date" to date,
|
||||||
|
"savedAt" to com.google.firebase.Timestamp.now()
|
||||||
|
)
|
||||||
|
db.collection(FirestoreCollections.COUPLES)
|
||||||
|
.document(coupleId)
|
||||||
|
.collection("lore")
|
||||||
|
.document(questionId)
|
||||||
|
.set(doc)
|
||||||
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
data class DailyQuestionAssignment(
|
data class DailyQuestionAssignment(
|
||||||
val questionId: String,
|
val questionId: String,
|
||||||
val date: String,
|
val date: String,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import app.closer.domain.repository.QuestionRepository
|
||||||
|
|
||||||
class FakeQuestionRepository : QuestionRepository {
|
class FakeQuestionRepository : QuestionRepository {
|
||||||
override suspend fun getDailyQuestion(): Question? = null
|
override suspend fun getDailyQuestion(): Question? = null
|
||||||
|
override suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question? = null
|
||||||
|
|
||||||
override suspend fun getQuestionById(id: String): Question? = null
|
override suspend fun getQuestionById(id: String): Question? = null
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,18 @@ class RoomQuestionRepository @Inject constructor(
|
||||||
return questionDao.getDailyQuestion()?.toQuestion()
|
return questionDao.getDailyQuestion()?.toQuestion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question? {
|
||||||
|
return if (isPremium) {
|
||||||
|
questionDao.getDailyQuestionByModeTag(modeTag)?.toQuestion()
|
||||||
|
?: questionDao.getDailyQuestionFromPack()?.toQuestion()
|
||||||
|
?: questionDao.getDailyQuestion()?.toQuestion()
|
||||||
|
} else {
|
||||||
|
questionDao.getFreeDailyQuestionByModeTag(modeTag)?.toQuestion()
|
||||||
|
?: questionDao.getFreeDailyQuestionFromPack()?.toQuestion()
|
||||||
|
?: questionDao.getDailyQuestion()?.toQuestion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getQuestionById(id: String): Question? {
|
override suspend fun getQuestionById(id: String): Question? {
|
||||||
return questionDao.getQuestionById(id)?.toQuestion()
|
return questionDao.getQuestionById(id)?.toQuestion()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
interface QuestionRepository {
|
||||||
suspend fun getDailyQuestion(): Question?
|
suspend fun getDailyQuestion(): Question?
|
||||||
|
suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question?
|
||||||
suspend fun getQuestionById(id: String): Question?
|
suspend fun getQuestionById(id: String): Question?
|
||||||
suspend fun getQuestionsByCategory(categoryId: String): List<Question>
|
suspend fun getQuestionsByCategory(categoryId: String): List<Question>
|
||||||
suspend fun getCategories(): List<QuestionCategory>
|
suspend fun getCategories(): List<QuestionCategory>
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.domain.model.LocalAnswer
|
import app.closer.domain.model.LocalAnswer
|
||||||
import app.closer.domain.model.Question
|
import app.closer.domain.model.Question
|
||||||
|
import app.closer.domain.DailyModeResolver
|
||||||
import app.closer.ui.questions.displayCategoryName
|
import app.closer.ui.questions.displayCategoryName
|
||||||
import app.closer.ui.questions.displayQuestionType
|
import app.closer.ui.questions.displayQuestionType
|
||||||
import app.closer.ui.components.BrandMessageRotator
|
import app.closer.ui.components.BrandMessageRotator
|
||||||
|
|
@ -83,6 +84,8 @@ fun AnswerRevealScreen(
|
||||||
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
|
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
|
||||||
onHome = { onNavigate(AppRoute.HOME) },
|
onHome = { onNavigate(AppRoute.HOME) },
|
||||||
onFollowUpSelected = { option -> viewModel.onFollowUpSelected(option, onNavigate) },
|
onFollowUpSelected = { option -> viewModel.onFollowUpSelected(option, onNavigate) },
|
||||||
|
onSaveLore = viewModel::saveCoupleLoré,
|
||||||
|
onTinyActionSaved = viewModel::onTinyActionSaved,
|
||||||
onSnackbarShown = viewModel::clearSnackbar
|
onSnackbarShown = viewModel::clearSnackbar
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +100,8 @@ private fun AnswerRevealContent(
|
||||||
onHome: () -> Unit,
|
onHome: () -> Unit,
|
||||||
onRefresh: () -> Unit = {},
|
onRefresh: () -> Unit = {},
|
||||||
onFollowUpSelected: (FollowUpOption) -> Unit = {},
|
onFollowUpSelected: (FollowUpOption) -> Unit = {},
|
||||||
|
onSaveLore: () -> Unit = {},
|
||||||
|
onTinyActionSaved: () -> Unit = {},
|
||||||
onSnackbarShown: () -> Unit = {}
|
onSnackbarShown: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -181,6 +186,11 @@ private fun AnswerRevealContent(
|
||||||
enter = if (reducedMotion) fadeIn(tween(0))
|
enter = if (reducedMotion) fadeIn(tween(0))
|
||||||
else fadeIn(tween(380)) + expandVertically(tween(380))
|
else fadeIn(tween(380)) + expandVertically(tween(380))
|
||||||
) {
|
) {
|
||||||
|
val tinyActionMode = state.question?.tags
|
||||||
|
?.firstOrNull { it.startsWith("mode_") }
|
||||||
|
?.removePrefix("mode_")
|
||||||
|
?.let { DailyModeResolver.getMode(it) }
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||||
RevealedState(
|
RevealedState(
|
||||||
answer = state.answer,
|
answer = state.answer,
|
||||||
|
|
@ -188,8 +198,16 @@ private fun AnswerRevealContent(
|
||||||
question = state.question,
|
question = state.question,
|
||||||
onHistory = onHistory,
|
onHistory = onHistory,
|
||||||
onHome = onHome,
|
onHome = onHome,
|
||||||
|
onSaveLore = onSaveLore,
|
||||||
|
loreSaved = state.loreSaved,
|
||||||
wasSealed = state.answer.schemaVersion == 3
|
wasSealed = state.answer.schemaVersion == 3
|
||||||
)
|
)
|
||||||
|
if (tinyActionMode != null) {
|
||||||
|
TinyActionCard(
|
||||||
|
mode = tinyActionMode,
|
||||||
|
onDoThis = onTinyActionSaved
|
||||||
|
)
|
||||||
|
}
|
||||||
if (state.followUpOptions.isNotEmpty()) {
|
if (state.followUpOptions.isNotEmpty()) {
|
||||||
FollowUpSection(
|
FollowUpSection(
|
||||||
options = state.followUpOptions,
|
options = state.followUpOptions,
|
||||||
|
|
@ -321,6 +339,29 @@ private fun ReadyToRevealState(
|
||||||
|
|
||||||
// ── Sealed-answer reveal states (Batch 11) ───────────────────────────────────
|
// ── Sealed-answer reveal states (Batch 11) ───────────────────────────────────
|
||||||
|
|
||||||
|
private val waitingCopy = listOf(
|
||||||
|
"Your answer is in. Now we wait dramatically.",
|
||||||
|
"Locked in. Let's see what they choose.",
|
||||||
|
"You picked. The suspense is tiny but real.",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val matchedCopy = listOf(
|
||||||
|
"You matched. Suspiciously cute.",
|
||||||
|
"Same pick. The lore deepens.",
|
||||||
|
"Both chose this. Tiny destiny.",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val differentCopy = listOf(
|
||||||
|
"Different picks. Honestly, useful.",
|
||||||
|
"Two vibes entered the chat.",
|
||||||
|
"Not a match, but very on-brand.",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun dayIndexCopy(list: List<String>): String {
|
||||||
|
val doy = java.util.Calendar.getInstance().get(java.util.Calendar.DAY_OF_YEAR)
|
||||||
|
return list[doy % list.size]
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AnswerSealedState(
|
private fun AnswerSealedState(
|
||||||
question: Question?,
|
question: Question?,
|
||||||
|
|
@ -338,10 +379,10 @@ private fun AnswerSealedState(
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Waiting for your partner. Reveal opens once both of you have answered.",
|
text = dayIndexCopy(waitingCopy),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 3,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
|
|
@ -373,10 +414,10 @@ private fun BothAnsweredSealedState(
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Reveal is ready. Open it when you're both here — answers are exchanged privately between your devices.",
|
text = "Both of you picked. Tap reveal to find out.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 4,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
|
@ -512,8 +553,20 @@ private fun RevealedState(
|
||||||
question: Question?,
|
question: Question?,
|
||||||
onHistory: () -> Unit,
|
onHistory: () -> Unit,
|
||||||
onHome: () -> Unit,
|
onHome: () -> Unit,
|
||||||
|
onSaveLore: () -> Unit = {},
|
||||||
|
loreSaved: Boolean = false,
|
||||||
wasSealed: Boolean = false
|
wasSealed: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
val matchLine = if (partnerAnswer != null) {
|
||||||
|
val ownIds = answer.selectedOptionIds.toSet()
|
||||||
|
val partnerIds = partnerAnswer.selectedOptionIds.toSet()
|
||||||
|
when {
|
||||||
|
ownIds.isNotEmpty() && ownIds == partnerIds -> dayIndexCopy(matchedCopy)
|
||||||
|
ownIds.isNotEmpty() && partnerIds.isNotEmpty() -> dayIndexCopy(differentCopy)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
|
||||||
RevealMessageCard {
|
RevealMessageCard {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
|
@ -521,6 +574,15 @@ private fun RevealedState(
|
||||||
RevealPill(answer.category.displayCategoryName())
|
RevealPill(answer.category.displayCategoryName())
|
||||||
RevealPill(answer.answerType.displayQuestionType())
|
RevealPill(answer.answerType.displayQuestionType())
|
||||||
}
|
}
|
||||||
|
if (matchLine != null) {
|
||||||
|
Text(
|
||||||
|
text = matchLine,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF56306F),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = question?.text ?: answer.questionText,
|
text = question?.text ?: answer.questionText,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
|
@ -555,6 +617,69 @@ private fun RevealedState(
|
||||||
Text("Home")
|
Text("Home")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!loreSaved) {
|
||||||
|
androidx.compose.material3.TextButton(
|
||||||
|
onClick = onSaveLore,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Save to Couple Lore",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color(0xFF56306F).copy(alpha = 0.75f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Saved to Couple Lore.",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TinyActionCard(
|
||||||
|
mode: DailyModeResolver.DailyMode,
|
||||||
|
onDoThis: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF0F8)),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = mode.actionCopy,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color(0xFF56306F),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = onDoThis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 40.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFFB98AF4),
|
||||||
|
contentColor = Color(0xFF24122F)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Do this tonight", style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package app.closer.ui.answers
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.analytics.RetentionAnalytics
|
||||||
|
import app.closer.analytics.RetentionEvent
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.core.crash.CrashReporter
|
import app.closer.core.crash.CrashReporter
|
||||||
import app.closer.crypto.PendingAnswerKeyStore
|
import app.closer.crypto.PendingAnswerKeyStore
|
||||||
|
|
@ -61,7 +63,8 @@ data class AnswerRevealUiState(
|
||||||
val partnerId: String? = null,
|
val partnerId: String? = null,
|
||||||
val followUpOptions: List<FollowUpOption> = emptyList(),
|
val followUpOptions: List<FollowUpOption> = emptyList(),
|
||||||
val snackbarMessage: String? = null,
|
val snackbarMessage: String? = null,
|
||||||
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE
|
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE,
|
||||||
|
val loreSaved: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -74,6 +77,7 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
private val crashReporter: CrashReporter,
|
private val crashReporter: CrashReporter,
|
||||||
private val sealedRevealManager: SealedRevealManager,
|
private val sealedRevealManager: SealedRevealManager,
|
||||||
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
||||||
|
private val retentionAnalytics: RetentionAnalytics,
|
||||||
savedStateHandle: SavedStateHandle
|
savedStateHandle: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -182,6 +186,10 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
|
|
||||||
fun revealAnswer() {
|
fun revealAnswer() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
|
retentionAnalytics.track(RetentionEvent.RevealOpened(
|
||||||
|
categoryId = state.answer?.category,
|
||||||
|
coupleIdHash = state.coupleId?.coupleLoreHash()
|
||||||
|
))
|
||||||
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
|
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
|
||||||
performSealedReveal(state)
|
performSealedReveal(state)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -269,6 +277,11 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
val ownAnswer = localAnswerRepository.getAnswer(questionId)
|
val ownAnswer = localAnswerRepository.getAnswer(questionId)
|
||||||
val category = ownAnswer?.category ?: state.question?.category ?: ""
|
val category = ownAnswer?.category ?: state.question?.category ?: ""
|
||||||
|
|
||||||
|
retentionAnalytics.track(RetentionEvent.RevealCompleted(
|
||||||
|
categoryId = category,
|
||||||
|
coupleIdHash = state.coupleId?.coupleLoreHash()
|
||||||
|
))
|
||||||
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
answer = ownAnswer,
|
answer = ownAnswer,
|
||||||
|
|
@ -285,6 +298,10 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
val answer = localAnswerRepository.getAnswer(questionId)
|
val answer = localAnswerRepository.getAnswer(questionId)
|
||||||
val partnerAnswer = _uiState.value.partnerAnswer
|
val partnerAnswer = _uiState.value.partnerAnswer
|
||||||
val category = answer?.category ?: _uiState.value.question?.category ?: ""
|
val category = answer?.category ?: _uiState.value.question?.category ?: ""
|
||||||
|
retentionAnalytics.track(RetentionEvent.RevealCompleted(
|
||||||
|
categoryId = category,
|
||||||
|
coupleIdHash = _uiState.value.coupleId?.coupleLoreHash()
|
||||||
|
))
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category))
|
it.copy(followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category))
|
||||||
}
|
}
|
||||||
|
|
@ -322,6 +339,40 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
route?.let { onNavigate(it) } ?: showSnackbar("Coming soon")
|
route?.let { onNavigate(it) } ?: showSnackbar("Coming soon")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveCoupleLoré() {
|
||||||
|
val state = _uiState.value
|
||||||
|
val coupleId = state.coupleId ?: return
|
||||||
|
val answer = state.answer ?: return
|
||||||
|
if (state.loreSaved) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val modeTag = state.question?.tags?.firstOrNull { it.startsWith("mode_") }
|
||||||
|
runCatching {
|
||||||
|
firestoreAnswerDataSource.saveLoreEntry(
|
||||||
|
coupleId = coupleId,
|
||||||
|
questionId = answer.questionId,
|
||||||
|
questionText = state.question?.text ?: answer.questionText,
|
||||||
|
ownAnswer = answer.revealSummaryText(),
|
||||||
|
partnerAnswer = state.partnerAnswer?.revealSummaryText(),
|
||||||
|
modeTag = modeTag,
|
||||||
|
date = effectiveDate(answer)
|
||||||
|
)
|
||||||
|
}.onFailure { crashReporter.recordException(it) }
|
||||||
|
_uiState.update { it.copy(loreSaved = true, snackbarMessage = "Saved to Couple Lore.") }
|
||||||
|
retentionAnalytics.track(RetentionEvent.CoupleLoreSaved(
|
||||||
|
categoryId = answer.category,
|
||||||
|
coupleIdHash = coupleId.coupleLoreHash()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTinyActionSaved() {
|
||||||
|
val state = _uiState.value
|
||||||
|
retentionAnalytics.track(RetentionEvent.DailyTinyActionSaved(
|
||||||
|
categoryId = state.answer?.category,
|
||||||
|
coupleIdHash = state.coupleId?.coupleLoreHash()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fun showSnackbar(message: String) {
|
fun showSnackbar(message: String) {
|
||||||
_uiState.update { it.copy(snackbarMessage = message) }
|
_uiState.update { it.copy(snackbarMessage = message) }
|
||||||
}
|
}
|
||||||
|
|
@ -330,6 +381,23 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
_uiState.update { it.copy(snackbarMessage = null) }
|
_uiState.update { it.copy(snackbarMessage = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String.coupleLoreHash(): String {
|
||||||
|
return try {
|
||||||
|
java.security.MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(toByteArray())
|
||||||
|
.take(8)
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
} catch (e: Exception) { "hash_error" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LocalAnswer.revealSummaryText(): String = when (answerType) {
|
||||||
|
"written" -> writtenText.orEmpty()
|
||||||
|
"scale" -> "Chose ${scaleValue ?: "-"}"
|
||||||
|
"single_choice", "multi_choice", "this_or_that" ->
|
||||||
|
selectedOptionTexts.ifEmpty { selectedOptionIds }.joinToString()
|
||||||
|
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "" }
|
||||||
|
}
|
||||||
|
|
||||||
private fun effectiveDate(answer: LocalAnswer?): String =
|
private fun effectiveDate(answer: LocalAnswer?): String =
|
||||||
answer?.answerDate?.takeIf { it.isNotBlank() }
|
answer?.answerDate?.takeIf { it.isNotBlank() }
|
||||||
?: FirestoreAnswerDataSource.todayLocalDateString()
|
?: FirestoreAnswerDataSource.todayLocalDateString()
|
||||||
|
|
|
||||||
|
|
@ -367,9 +367,9 @@ private fun StreakCard(
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val copy = when (streakCount) {
|
val copy = when (streakCount) {
|
||||||
0 -> "Start a new streak today"
|
0 -> "Your little ritual is waiting."
|
||||||
1 -> "1 day streak"
|
1 -> "1 day showing up"
|
||||||
else -> "$streakCount day streak"
|
else -> "$streakCount days showing up"
|
||||||
}
|
}
|
||||||
val partnerLine = if (streakCount > 0 && !partnerName.isNullOrBlank()) "with $partnerName" else null
|
val partnerLine = if (streakCount > 0 && !partnerName.isNullOrBlank()) "with $partnerName" else null
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -350,9 +350,9 @@ private fun PartnerIdentityCard(
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = when (streakCount) {
|
text = when (streakCount) {
|
||||||
0 -> "Start a streak together"
|
0 -> "Start your little ritual together"
|
||||||
1 -> "1 day streak"
|
1 -> "1 day showing up"
|
||||||
else -> "$streakCount day streak"
|
else -> "$streakCount days showing up"
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,14 @@ fun DailyQuestionScreen(
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val mode = state.dailyMode
|
||||||
|
val title = mode?.title ?: "One question, enough space"
|
||||||
|
val subtitle = mode?.subtitle ?: "Answer privately first, then choose whether to reveal it or keep the conversation going."
|
||||||
|
|
||||||
LocalQuestionContent(
|
LocalQuestionContent(
|
||||||
state = state,
|
state = state,
|
||||||
title = "One question, enough space",
|
title = title,
|
||||||
subtitle = "Answer privately first, then choose whether to reveal it or keep the conversation going.",
|
subtitle = subtitle,
|
||||||
primaryRouteLabel = "Discuss",
|
primaryRouteLabel = "Discuss",
|
||||||
onPrimaryRoute = { question ->
|
onPrimaryRoute = { question ->
|
||||||
val coupleId = state.coupleId
|
val coupleId = state.coupleId
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,12 @@ package app.closer.ui.questions
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.analytics.RetentionAnalytics
|
||||||
|
import app.closer.analytics.RetentionEvent
|
||||||
|
import app.closer.core.billing.EntitlementChecker
|
||||||
import app.closer.core.crash.CrashReporter
|
import app.closer.core.crash.CrashReporter
|
||||||
import app.closer.data.remote.FirestoreAnswerDataSource
|
import app.closer.data.remote.FirestoreAnswerDataSource
|
||||||
|
import app.closer.domain.DailyModeResolver
|
||||||
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
||||||
import app.closer.domain.model.LocalAnswer
|
import app.closer.domain.model.LocalAnswer
|
||||||
import app.closer.domain.model.Question
|
import app.closer.domain.model.Question
|
||||||
|
|
@ -18,6 +22,7 @@ import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -32,7 +37,8 @@ data class LocalQuestionUiState(
|
||||||
val pendingWrittenText: String = "",
|
val pendingWrittenText: String = "",
|
||||||
val pendingSelectedOptionIds: List<String> = emptyList(),
|
val pendingSelectedOptionIds: List<String> = emptyList(),
|
||||||
val pendingScaleValue: Int = 3,
|
val pendingScaleValue: Int = 3,
|
||||||
val partnerHasAnswered: Boolean = false
|
val partnerHasAnswered: Boolean = false,
|
||||||
|
val dailyMode: DailyModeResolver.DailyMode? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -43,6 +49,8 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
private val crashReporter: CrashReporter,
|
private val crashReporter: CrashReporter,
|
||||||
|
private val entitlementChecker: EntitlementChecker,
|
||||||
|
private val retentionAnalytics: RetentionAnalytics,
|
||||||
private val db: FirebaseFirestore
|
private val db: FirebaseFirestore
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -65,8 +73,9 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = LocalQuestionUiState(isLoading = true)
|
_uiState.value = LocalQuestionUiState(isLoading = true)
|
||||||
try {
|
try {
|
||||||
|
val resolvedMode = DailyModeResolver.resolve()
|
||||||
val today = FirestoreAnswerDataSource.todayLocalDateString()
|
val today = FirestoreAnswerDataSource.todayLocalDateString()
|
||||||
val (coupleId, question) = loadCoupleAndQuestion(today)
|
val (coupleId, question) = loadCoupleAndQuestion(today, resolvedMode)
|
||||||
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
|
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
|
||||||
val partnerHasAnswered = coupleId?.let {
|
val partnerHasAnswered = coupleId?.let {
|
||||||
runCatching { checkPartnerAnswered(it, today) }.getOrDefault(false)
|
runCatching { checkPartnerAnswered(it, today) }.getOrDefault(false)
|
||||||
|
|
@ -77,11 +86,15 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
coupleId = coupleId,
|
coupleId = coupleId,
|
||||||
dailyQuestionDate = today,
|
dailyQuestionDate = today,
|
||||||
pendingScaleValue = defaultScaleValue(question),
|
pendingScaleValue = defaultScaleValue(question),
|
||||||
partnerHasAnswered = partnerHasAnswered
|
partnerHasAnswered = partnerHasAnswered,
|
||||||
|
dailyMode = resolvedMode
|
||||||
).withLocalAnswer(answer)
|
).withLocalAnswer(answer)
|
||||||
|
|
||||||
if (coupleId != null) startPartnerAnswerObserver(coupleId, today)
|
if (coupleId != null) startPartnerAnswerObserver(coupleId, today)
|
||||||
question?.let { observeLocalAnswerRevealed(it.id) }
|
question?.let { observeLocalAnswerRevealed(it.id) }
|
||||||
|
|
||||||
|
retentionAnalytics.track(RetentionEvent.DailyQuestionViewed(categoryId = question?.category))
|
||||||
|
retentionAnalytics.track(RetentionEvent.DailyModeResolved(categoryId = resolvedMode.id))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
crashReporter.recordException(e)
|
crashReporter.recordException(e)
|
||||||
_uiState.value = LocalQuestionUiState(
|
_uiState.value = LocalQuestionUiState(
|
||||||
|
|
@ -124,12 +137,16 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
*
|
*
|
||||||
* For paired users, read the couple's assigned daily question from Firestore
|
* For paired users, read the couple's assigned daily question from Firestore
|
||||||
* so both partners see the same prompt. If no assignment exists yet, request
|
* so both partners see the same prompt. If no assignment exists yet, request
|
||||||
* one from the cloud function and fall back to a local random question while
|
* one from the cloud function and fall back to a local mode-tagged question.
|
||||||
* waiting.
|
|
||||||
*
|
*
|
||||||
* For unpaired users, fall back to the local random question pool.
|
* For unpaired users, fall back to a mode-tagged question from the daily_fun_mc pack.
|
||||||
*/
|
*/
|
||||||
private suspend fun loadCoupleAndQuestion(today: String): Pair<String?, Question?> {
|
private suspend fun loadCoupleAndQuestion(
|
||||||
|
today: String,
|
||||||
|
mode: DailyModeResolver.DailyMode
|
||||||
|
): Pair<String?, Question?> {
|
||||||
|
val isPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false)
|
||||||
|
|
||||||
val couple = authRepository.currentUserId?.let { uid ->
|
val couple = authRepository.currentUserId?.let { uid ->
|
||||||
runCatching { coupleRepository.getCoupleForUser(uid) }
|
runCatching { coupleRepository.getCoupleForUser(uid) }
|
||||||
.onFailure { crashReporter.recordException(it) }
|
.onFailure { crashReporter.recordException(it) }
|
||||||
|
|
@ -137,7 +154,8 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (couple == null) {
|
if (couple == null) {
|
||||||
return null to repository.getDailyQuestion()
|
return null to (repository.getDailyQuestionForMode(mode.modeTag, isPremium)
|
||||||
|
?: repository.getDailyQuestion())
|
||||||
}
|
}
|
||||||
|
|
||||||
val coupleId = couple.id
|
val coupleId = couple.id
|
||||||
|
|
@ -146,16 +164,19 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
|
|
||||||
val question = if (assignment != null) {
|
val question = if (assignment != null) {
|
||||||
repository.getQuestionById(assignment.questionId) ?: repository.getDailyQuestion()
|
repository.getQuestionById(assignment.questionId)
|
||||||
|
?: repository.getDailyQuestionForMode(mode.modeTag, isPremium)
|
||||||
|
?: repository.getDailyQuestion()
|
||||||
} else {
|
} else {
|
||||||
// No assignment yet. Request immediate assignment, but keep the app
|
// No assignment yet. Request immediate assignment, but keep the app
|
||||||
// usable with a local random question in case the call fails.
|
// usable with a local mode-tagged question in case the call fails.
|
||||||
runCatching {
|
runCatching {
|
||||||
firestoreAnswerDataSource.requestDailyQuestionAssignment(coupleId, today)
|
firestoreAnswerDataSource.requestDailyQuestionAssignment(coupleId, today)
|
||||||
repository.getQuestionById(
|
repository.getQuestionById(
|
||||||
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
|
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
|
||||||
)
|
)
|
||||||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
|
?: repository.getDailyQuestionForMode(mode.modeTag, isPremium)
|
||||||
?: repository.getDailyQuestion()
|
?: repository.getDailyQuestion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,6 +216,7 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
val localAnswer = state.toLocalAnswer(question).copy(updatedAt = System.currentTimeMillis())
|
val localAnswer = state.toLocalAnswer(question).copy(updatedAt = System.currentTimeMillis())
|
||||||
localAnswerRepository.saveAnswer(localAnswer)
|
localAnswerRepository.saveAnswer(localAnswer)
|
||||||
_uiState.update { it.copy(submitted = true) }
|
_uiState.update { it.copy(submitted = true) }
|
||||||
|
retentionAnalytics.track(RetentionEvent.DailyQuestionAnswered(categoryId = question.category))
|
||||||
syncAnswerToFirestore(state.coupleId, state.dailyQuestionDate, question.id, localAnswer)
|
syncAnswerToFirestore(state.coupleId, state.dailyQuestionDate, question.id, localAnswer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue