feat: nav, capsule data source, challenges, desire sync, question category, wheel history + viewmodel

This commit is contained in:
null 2026-06-22 21:19:19 -05:00
parent 2121fe5562
commit 1905938c80
7 changed files with 315 additions and 338 deletions

View File

@ -78,7 +78,7 @@ import app.closer.ui.outcomes.YourProgressScreen
import app.closer.ui.wheel.CategoryPickerScreen
import app.closer.ui.wheel.SpinWheelScreen
import app.closer.ui.wheel.WheelCompleteScreen
import app.closer.ui.wheel.WheelHistoryScreen
import app.closer.ui.wheel.GameHistoryScreen
import app.closer.ui.wheel.WheelSessionScreen
import app.closer.ui.games.WaitingForPartnerScreen
@ -371,10 +371,10 @@ fun AppNavigation(
)
}
composable(route = AppRoute.WHEEL_HISTORY) {
WheelHistoryScreen(onNavigate = navigateRoute)
GameHistoryScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.GAME_HISTORY) {
WheelHistoryScreen(onNavigate = navigateRoute)
GameHistoryScreen(onNavigate = navigateRoute)
}
composable(
route = AppRoute.THIS_OR_THAT_REPLAY,

View File

@ -1,5 +1,8 @@
package app.closer.data.remote
import android.util.Log
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.TimeCapsule
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose
@ -9,46 +12,72 @@ import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "FirestoreCapsuleDS"
@Singleton
class FirestoreCapsuleDataSource @Inject constructor(private val db: FirebaseFirestore) {
class FirestoreCapsuleDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
private fun col(coupleId: String) =
db.collection(FirestoreCollections.COUPLES)
.document(coupleId)
.collection(FirestoreCollections.Couples.CAPSULES)
private fun decryptField(value: String?, coupleId: String): String? {
if (value == null) return null
val aead = encryptionManager.aeadFor(coupleId)
return runCatching { fieldEncryptor.decrypt(value, aead, coupleId) }
.getOrElse { e ->
Log.w(TAG, "decrypt failed, returning raw: ${e.message}")
value
}
}
private fun mapToCapsule(doc: com.google.firebase.firestore.DocumentSnapshot, coupleId: String): TimeCapsule {
val rawTitle = doc.getString("title") ?: ""
val rawContent = doc.getString("content") ?: ""
val rawPrompt = doc.getString("promptUsed")
return TimeCapsule(
id = doc.id,
coupleId = coupleId,
authorId = doc.getString("authorId") ?: "",
title = decryptField(rawTitle, coupleId) ?: rawTitle,
content = decryptField(rawContent, coupleId) ?: rawContent,
promptUsed = decryptField(rawPrompt, coupleId),
unlockAt = doc.getLong("unlockAt") ?: 0L,
createdAt = doc.getLong("createdAt") ?: 0L,
status = doc.getString("status") ?: "sealed"
)
}
fun observeCapsules(coupleId: String): Flow<List<TimeCapsule>> = callbackFlow {
val reg = col(coupleId)
.orderBy("createdAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { doc ->
runCatching {
TimeCapsule(
id = doc.id,
coupleId = coupleId,
authorId = doc.getString("authorId") ?: "",
title = doc.getString("title") ?: "",
content = doc.getString("content") ?: "",
promptUsed = doc.getString("promptUsed"),
unlockAt = doc.getLong("unlockAt") ?: 0L,
createdAt = doc.getLong("createdAt") ?: 0L,
status = doc.getString("status") ?: "sealed"
)
}.getOrNull()
runCatching { mapToCapsule(doc, coupleId) }.getOrNull()
})
}
awaitClose { reg.remove() }
}
suspend fun createCapsule(capsule: TimeCapsule): String {
val aead = encryptionManager.aeadFor(capsule.coupleId)
val encTitle = aead?.let { fieldEncryptor.encrypt(capsule.title, it, capsule.coupleId) } ?: capsule.title
val encContent = aead?.let { fieldEncryptor.encrypt(capsule.content, it, capsule.coupleId) } ?: capsule.content
val encPrompt = aead?.let { fieldEncryptor.encryptNullable(capsule.promptUsed, it, capsule.coupleId) } ?: capsule.promptUsed
val ref = col(capsule.coupleId).document()
ref.set(
mapOf(
"authorId" to capsule.authorId,
"title" to capsule.title,
"content" to capsule.content,
"promptUsed" to capsule.promptUsed,
"title" to encTitle,
"content" to encContent,
"promptUsed" to encPrompt,
"unlockAt" to capsule.unlockAt,
"createdAt" to capsule.createdAt,
"status" to "sealed"
@ -64,19 +93,7 @@ class FirestoreCapsuleDataSource @Inject constructor(private val db: FirebaseFir
.await()
.documents
.mapNotNull { doc ->
runCatching {
TimeCapsule(
id = doc.id,
coupleId = coupleId,
authorId = doc.getString("authorId") ?: "",
title = doc.getString("title") ?: "",
content = doc.getString("content") ?: "",
promptUsed = doc.getString("promptUsed"),
unlockAt = doc.getLong("unlockAt") ?: 0L,
createdAt = doc.getLong("createdAt") ?: 0L,
status = doc.getString("status") ?: "sealed"
)
}.getOrNull()
runCatching { mapToCapsule(doc, coupleId) }.getOrNull()
}
suspend fun unlockCapsule(coupleId: String, capsuleId: String) {

View File

@ -53,10 +53,15 @@ import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import app.closer.data.challenges.ChallengesCatalog
import app.closer.data.remote.FirestoreChallengeDataSource
import app.closer.domain.ChallengeStateMachine
import app.closer.domain.model.ChallengeProgressState
import app.closer.domain.model.ChallengeState
import app.closer.domain.model.ChallengeStateInput
import app.closer.domain.model.ChallengeStatus
import app.closer.domain.model.ConnectionChallenge
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import java.time.LocalDate
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush
import dagger.hilt.android.lifecycle.HiltViewModel
@ -76,6 +81,7 @@ data class ChallengesUiState(
val phase: ChallengesPhase = ChallengesPhase.LOADING,
val activeChallenge: ConnectionChallenge? = null,
val progress: ChallengeProgressState? = null,
val challengeState: ChallengeState? = null,
val coupleId: String? = null,
val userId: String? = null,
val partnerId: String? = null,
@ -144,6 +150,13 @@ class ConnectionChallengesViewModel @Inject constructor(
progressJob = viewModelScope.launch {
challengeDataSource.observeProgress(coupleId, challenge.id, userId, partnerId)
.collect { progress ->
val state = ChallengeStateMachine.compute(
ChallengeStateInput(
challenge = challenge,
progress = progress,
today = LocalDate.now()
)
)
_uiState.update {
it.copy(
phase = ChallengesPhase.ACTIVE,
@ -151,13 +164,11 @@ class ConnectionChallengesViewModel @Inject constructor(
userId = userId,
partnerId = partnerId,
activeChallenge = challenge,
progress = progress
progress = progress,
challengeState = state
)
}
// Auto-complete challenge when all days jointly done.
if (progress.jointCompletedDays.size == challenge.durationDays &&
progress.status == "active"
) {
if (state.isComplete && progress.status == "active") {
runCatching { challengeDataSource.finishChallenge(coupleId, challenge.id) }
}
}
@ -230,6 +241,7 @@ fun ConnectionChallengesScreen(
ChallengesPhase.ACTIVE -> ChallengesActiveScreen(
challenge = state.activeChallenge!!,
progress = state.progress ?: ChallengeProgressState(),
challengeState = state.challengeState,
onBack = { onNavigate(AppRoute.PLAY) },
onMarkComplete = { viewModel.markTodayComplete() }
)
@ -398,17 +410,17 @@ private fun ChallengePickCard(
private fun ChallengesActiveScreen(
challenge: ConnectionChallenge,
progress: ChallengeProgressState,
challengeState: ChallengeState?,
onBack: () -> Unit,
onMarkComplete: () -> Unit
) {
val alreadyDoneToday = progress.myNextDay > (progress.myCompletedDays.maxOrNull() ?: 0) + 1 ||
progress.myCompletedDays.contains(progress.myNextDay - 1)
// Has current user completed today?
val todayDone = progress.myCompletedDays.contains(progress.myNextDay.coerceAtMost(challenge.durationDays)) ||
progress.myNextDay > challenge.durationDays
val allComplete = progress.isComplete || progress.jointCompletedDays.size == challenge.durationDays
val cs = challengeState
val isComplete = cs?.isComplete == true || cs?.state == ChallengeStatus.CHALLENGE_COMPLETE
val currentDay = cs?.currentDay ?: progress.myNextDay.coerceAtMost(challenge.durationDays)
val canAdvance = cs?.canAdvance ?: true
val stateCopy = cs?.copy ?: ""
val ctaLabel: String? = cs?.cta
val missedDay = cs?.missedDate
LazyColumn(
modifier = Modifier
@ -419,7 +431,6 @@ private fun ChallengesActiveScreen(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
@ -441,13 +452,12 @@ private fun ChallengesActiveScreen(
overflow = TextOverflow.Ellipsis
)
Text(
text = if (allComplete) "Completed 🎉" else "Day ${progress.myNextDay.coerceAtMost(challenge.durationDays)} of ${challenge.durationDays}",
text = if (isComplete) "${cs?.badge ?: "🏅"} Completed" else "Day $currentDay of ${challenge.durationDays}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Streak badge
if (progress.jointStreak > 0) {
if (progress.jointStreak > 0 && !isComplete) {
Surface(
shape = RoundedCornerShape(999.dp),
color = CloserPalette.PurpleDeep.copy(alpha = 0.12f)
@ -465,22 +475,44 @@ private fun ChallengesActiveScreen(
}
item {
// Day tracker strip
DayTrackerStrip(
totalDays = challenge.durationDays,
myCompletedDays = progress.myCompletedDays,
partnerCompletedDays = progress.partnerCompletedDays,
currentDay = progress.myNextDay
currentDay = currentDay
)
}
if (!allComplete) {
val displayDay = progress.myNextDay.coerceAtMost(challenge.durationDays)
val dayPrompt = challenge.days.getOrNull(displayDay - 1)
// Missed-day banner
if (missedDay != null && !isComplete) {
item {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text("⚠️", style = MaterialTheme.typography.bodyMedium)
Text(
text = "You missed a day — no worries, just pick it back up.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
if (!isComplete) {
val dayPrompt = challenge.days.getOrNull(currentDay - 1)
if (dayPrompt != null) {
item {
// Today's prompt card
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
@ -496,7 +528,7 @@ private fun ChallengesActiveScreen(
color = CloserPalette.PurpleDeep.copy(alpha = 0.10f)
) {
Text(
text = "Day $displayDay",
text = "Day $currentDay",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = CloserPalette.PurpleDeep,
@ -520,60 +552,71 @@ private fun ChallengesActiveScreen(
}
}
item {
// Partner status row
val partnerDoneToday = progress.partnerCompletedDays.contains(displayDay)
Surface(
shape = RoundedCornerShape(16.dp),
color = if (partnerDoneToday)
CloserPalette.Evergreen.copy(alpha = 0.10f)
else
MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
// State machine copy — describes current state (waiting, partner done, etc.)
if (stateCopy.isNotBlank()) {
item {
val isWaiting = cs?.state == ChallengeStatus.WAITING_FOR_PARTNER
val isBothDone = cs?.state == ChallengeStatus.BOTH_COMPLETED_TODAY
Surface(
shape = RoundedCornerShape(16.dp),
color = when {
isBothDone -> CloserPalette.Evergreen.copy(alpha = 0.10f)
isWaiting -> CloserPalette.PurpleDeep.copy(alpha = 0.07f)
else -> MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
}
) {
if (partnerDoneToday) {
Icon(Icons.Filled.Check, contentDescription = null, tint = CloserPalette.Evergreen, modifier = Modifier.size(18.dp))
Text("Partner completed today", style = MaterialTheme.typography.bodySmall, color = CloserPalette.Evergreen, fontWeight = FontWeight.Medium)
} else {
Text("", style = MaterialTheme.typography.bodySmall)
Text("Waiting for your partner", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
val icon = when {
isBothDone -> Icons.Filled.Check
else -> null
}
if (icon != null) {
Icon(icon, contentDescription = null, tint = CloserPalette.Evergreen, modifier = Modifier.size(18.dp))
} else {
Text(if (isWaiting) "" else "💬", style = MaterialTheme.typography.bodySmall)
}
Text(
text = stateCopy,
style = MaterialTheme.typography.bodySmall,
color = if (isBothDone) CloserPalette.Evergreen else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (isBothDone) FontWeight.Medium else FontWeight.Normal
)
}
}
}
}
item {
// CTA
val iDoneToday = progress.myCompletedDays.contains(displayDay)
Button(
onClick = onMarkComplete,
enabled = !iDoneToday,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 54.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(
containerColor = CloserPalette.PurpleDeep,
disabledContainerColor = CloserPalette.PurpleDeep.copy(alpha = 0.35f)
)
) {
Text(
text = if (iDoneToday) "Done for today ✓" else "I did it today",
style = MaterialTheme.typography.labelLarge,
color = Color.White
)
if (ctaLabel != null) {
item {
Button(
onClick = onMarkComplete,
enabled = canAdvance,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 54.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(
containerColor = CloserPalette.PurpleDeep,
disabledContainerColor = CloserPalette.PurpleDeep.copy(alpha = 0.35f)
)
) {
Text(
text = ctaLabel,
style = MaterialTheme.typography.labelLarge,
color = Color.White
)
}
}
}
}
} else {
item {
// Completion state
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
@ -587,7 +630,7 @@ private fun ChallengesActiveScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("🎉", style = MaterialTheme.typography.displaySmall)
Text(cs?.badge ?: "🏅", style = MaterialTheme.typography.displaySmall)
Text(
text = "Challenge complete!",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
@ -595,7 +638,7 @@ private fun ChallengesActiveScreen(
textAlign = TextAlign.Center
)
Text(
text = "You and your partner finished \"${challenge.title}\" together. That's ${challenge.durationDays} days in a row.",
text = cs?.copy ?: "You and your partner finished \"${challenge.title}\" together. That's ${challenge.durationDays} days of showing up.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center

View File

@ -54,6 +54,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.navigation.AppRoute
import app.closer.data.remote.FirestoreDesireSyncDataSource
import android.content.Context
@ -116,7 +117,8 @@ class DesireSyncViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource
private val dataSource: FirestoreDesireSyncDataSource,
private val entitlementChecker: EntitlementChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState())
@ -137,6 +139,10 @@ class DesireSyncViewModel @Inject constructor(
private fun load() {
viewModelScope.launch {
if (!entitlementChecker.hasPremium()) {
_uiState.update { it.copy(navigateTo = AppRoute.PAYWALL) }
return@launch
}
val uid = gameSessionManager.currentUserId
?: return@launch fail("You need to be signed in to play.")
val couple = gameSessionManager.getCoupleForUser(uid)

View File

@ -2,15 +2,17 @@ package app.closer.ui.questions
import app.closer.ui.theme.closerCardColor
import app.closer.ui.theme.closerBackgroundBrush
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
@ -18,30 +20,35 @@ import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.R
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
@ -58,13 +65,16 @@ fun QuestionCategoryScreen(
QuestionCategoryContent(
categoryId = categoryId,
state = state,
onQuestionSelected = { question ->
val coupleId = state.coupleId
if (coupleId != null) {
onNavigate(AppRoute.questionThread(coupleId, question.id))
} else {
// Discussing requires a paired partner; send unpaired users to invite one.
onNavigate(AppRoute.CREATE_INVITE)
onBack = { onNavigate("back") },
onPickPrompt = {
val question = state.questions.randomOrNull()
if (question != null) {
val coupleId = state.coupleId
if (coupleId != null) {
onNavigate(AppRoute.questionThread(coupleId, question.id))
} else {
onNavigate(AppRoute.CREATE_INVITE)
}
}
}
)
@ -74,21 +84,15 @@ fun QuestionCategoryScreen(
private fun QuestionCategoryContent(
categoryId: String,
state: QuestionCategoryUiState,
onQuestionSelected: (Question) -> Unit
onBack: () -> Unit,
onPickPrompt: () -> Unit
) {
var selectedType by remember { mutableStateOf<String?>(null) }
val visibleQuestions = remember(state.questions, selectedType) {
state.questions.filter { question ->
selectedType == null || question.type == selectedType
}
}
val title = state.category?.displayName ?: categoryId.displayCategoryName()
Box(
modifier = Modifier
.fillMaxSize()
.background(
closerBackgroundBrush()
)
.background(closerBackgroundBrush())
) {
LazyColumn(
modifier = Modifier
@ -96,16 +100,35 @@ private fun QuestionCategoryContent(
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
val title = state.category?.displayName
?: categoryId.displayCategoryName()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onBackground
)
}
}
}
item {
PackArtworkHeader(categoryId = categoryId)
}
item {
CategoryHero(
title = title,
category = state.category,
questionCount = state.questions.size,
modifier = Modifier.padding(top = 20.dp, bottom = 6.dp)
isLoading = state.isLoading
)
}
@ -113,46 +136,35 @@ private fun QuestionCategoryContent(
state.isLoading -> item { CategoryLoadingCard() }
state.error != null -> item {
CategoryMessageCard(
title = "Category paused",
title = "Pack unavailable",
message = state.error
)
}
state.questions.isEmpty() -> item {
CategoryMessageCard(
title = "No prompts found",
message = "No prompts are available for ${categoryId.displayCategoryName()} right now."
)
}
else -> {
item {
CategoryFilters(
questions = state.questions,
selectedType = selectedType,
onTypeSelected = { selectedType = it }
else -> item {
Button(
onClick = onPickPrompt,
enabled = state.questions.isNotEmpty(),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 54.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF56306F),
contentColor = Color.White,
disabledContainerColor = Color(0xFF56306F).copy(alpha = 0.35f),
disabledContentColor = Color.White.copy(alpha = 0.54f)
)
) {
Text(
text = if (state.questions.isEmpty()) "No prompts yet" else "Pick a prompt",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
if (visibleQuestions.isEmpty()) {
item {
CategoryMessageCard(
title = "No prompts match",
message = "Try another filter to keep browsing."
)
}
} else {
visibleQuestions.groupBy { it.depthLevel }.toSortedMap().forEach { (depth, questions) ->
item(key = "depth-$depth") {
DepthHeader(depth = depth, count = questions.size)
}
items(questions, key = { it.id }) { question ->
QuestionListCard(
question = question,
onClick = { onQuestionSelected(question) }
)
}
}
}
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}
}
@ -162,10 +174,10 @@ private fun CategoryHero(
title: String,
category: QuestionCategory?,
questionCount: Int,
modifier: Modifier = Modifier
isLoading: Boolean = false
) {
Column(
modifier = modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
@ -192,182 +204,80 @@ private fun CategoryHero(
overflow = TextOverflow.Ellipsis
)
Text(
text = category?.description
?: "Browse prompts for this kind of conversation.",
text = category?.description ?: "Prompts for this kind of conversation.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CategoryPill("$questionCount ${if (questionCount == 1) "prompt" else "prompts"}", emphasis = true)
category?.access?.let { CategoryPill(it.displayCategoryName()) }
category?.iconName
?.takeIf { it.isNotBlank() }
?.let { it.displayCategoryName() }
?.takeIf { it != "Question" }
?.let { CategoryPill(it) }
}
}
}
@Composable
private fun CategoryFilters(
questions: List<Question>,
selectedType: String?,
onTypeSelected: (String?) -> Unit
) {
val types = questions.map { it.type }.distinct().sorted()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Format",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
(listOf(null) + types).forEach { option ->
FilterPill(
label = option?.displayQuestionFilterName() ?: "All",
selected = selectedType == option,
onClick = { onTypeSelected(option) }
)
}
}
}
}
private fun String.displayQuestionFilterName(): String {
return when (this) {
"single_choice" -> "Single"
"multi_choice" -> "Multi"
"this_or_that" -> "Either/or"
"scale" -> "Scale"
else -> "Written"
}
}
@Composable
private fun FilterPill(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
Surface(
modifier = Modifier
.heightIn(min = 44.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(999.dp),
color = if (selected) Color(0xFFF3E8FF) else Color.White.copy(alpha = 0.74f),
shadowElevation = if (selected) 2.dp else 0.dp
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 13.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = if (selected) Color(0xFF56306F) else MaterialTheme.colorScheme.onSurface,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun DepthHeader(depth: Int, count: Int) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp),
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.68f),
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 11.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = depthLabel(depth),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Depth $depth",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
CategoryPill("$count ${if (count == 1) "prompt" else "prompts"}", emphasis = true)
}
}
}
private fun depthLabel(depth: Int): String = when (depth) {
1 -> "Light openers"
2 -> "Closer prompts"
3 -> "Deeper conversation"
else -> "Depth $depth"
}
@Composable
private fun QuestionListCard(
question: Question,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.9f)),
elevation = CardDefaults.cardElevation(defaultElevation = 3.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = question.text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CategoryPill(question.type.displayQuestionType())
if (question.isPremium) {
CategoryPill("Premium")
} else {
CategoryPill("Free")
if (!isLoading) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CategoryPill("$questionCount ${if (questionCount == 1) "prompt" else "prompts"}", emphasis = true)
category?.access?.let { access ->
CategoryPill(
when (access) {
"premium" -> "Premium"
"mixed" -> "Some free"
else -> "Free"
}
)
}
}
}
}
}
@Composable
private fun PackArtworkHeader(categoryId: String) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.clip(RoundedCornerShape(24.dp))
) {
Image(
painter = painterResource(packArtworkRes(categoryId)),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
}
private fun packArtworkRes(categoryId: String): Int = when (categoryId) {
"communication" -> R.drawable.pack_art_communication
"trust",
"boundaries",
"conflict",
"conflict_repair",
"rebuilding_trust" -> R.drawable.pack_art_trust_repair
"emotional_intimacy",
"gratitude",
"couple_intimacy" -> R.drawable.pack_art_intimacy
"fun",
"date_night",
"quality_time" -> R.drawable.pack_art_fun_date
"future",
"values" -> R.drawable.pack_art_future_goals
"home_life",
"stress" -> R.drawable.pack_art_home_life
"money" -> R.drawable.pack_art_money_values
"marriage",
"parenting" -> R.drawable.pack_art_family_commitment
"sex_and_desire",
"sexual_preferences",
"physical_intimacy" -> R.drawable.pack_art_desire
"difficult_conversations" -> R.drawable.pack_art_deep_reflection
else -> R.drawable.pack_art_deep_reflection
}
@Composable
private fun CategoryPill(
label: String,
@ -467,6 +377,7 @@ fun QuestionCategoryScreenPreview() {
)
)
),
onQuestionSelected = {}
onBack = {},
onPickPrompt = {}
)
}

View File

@ -57,9 +57,9 @@ import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WheelHistoryScreen(
fun GameHistoryScreen(
onNavigate: (String) -> Unit = {},
viewModel: WheelHistoryViewModel = hiltViewModel()
viewModel: GameHistoryViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
@ -102,7 +102,7 @@ fun WheelHistoryScreen(
when {
!state.hasPremium -> item {
WheelHistoryLockedCard(onUnlock = { onNavigate(AppRoute.PAYWALL) })
GameHistoryLockedCard(onUnlock = { onNavigate(AppRoute.PAYWALL) })
}
state.isLoading -> item { LoadingState(message = "Loading your sessions…") }
state.error != null -> item {
@ -184,7 +184,7 @@ private fun sessionReplayRoute(session: QuestionSession): String = when (session
}
@Composable
private fun WheelHistoryLockedCard(onUnlock: () -> Unit) {
private fun GameHistoryLockedCard(onUnlock: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),

View File

@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class WheelHistoryUiState(
data class GameHistoryUiState(
val isLoading: Boolean = false,
val sessions: List<QuestionSession> = emptyList(),
val hasPremium: Boolean = false,
@ -25,15 +25,15 @@ data class WheelHistoryUiState(
)
@HiltViewModel
class WheelHistoryViewModel @Inject constructor(
class GameHistoryViewModel @Inject constructor(
private val sessionRepository: QuestionSessionRepository,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
entitlementChecker: EntitlementChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(WheelHistoryUiState())
val uiState: StateFlow<WheelHistoryUiState> = _uiState.asStateFlow()
private val _uiState = MutableStateFlow(GameHistoryUiState())
val uiState: StateFlow<GameHistoryUiState> = _uiState.asStateFlow()
init {
entitlementChecker.isPremium()