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

View File

@ -1,5 +1,8 @@
package app.closer.data.remote 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 app.closer.domain.model.TimeCapsule
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@ -9,46 +12,72 @@ import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val TAG = "FirestoreCapsuleDS"
@Singleton @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) = private fun col(coupleId: String) =
db.collection(FirestoreCollections.COUPLES) db.collection(FirestoreCollections.COUPLES)
.document(coupleId) .document(coupleId)
.collection(FirestoreCollections.Couples.CAPSULES) .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 { fun observeCapsules(coupleId: String): Flow<List<TimeCapsule>> = callbackFlow {
val reg = col(coupleId) val reg = col(coupleId)
.orderBy("createdAt", com.google.firebase.firestore.Query.Direction.DESCENDING) .orderBy("createdAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.addSnapshotListener { snap, err -> .addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { doc -> trySend(snap.documents.mapNotNull { doc ->
runCatching { runCatching { mapToCapsule(doc, coupleId) }.getOrNull()
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()
}) })
} }
awaitClose { reg.remove() } awaitClose { reg.remove() }
} }
suspend fun createCapsule(capsule: TimeCapsule): String { 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() val ref = col(capsule.coupleId).document()
ref.set( ref.set(
mapOf( mapOf(
"authorId" to capsule.authorId, "authorId" to capsule.authorId,
"title" to capsule.title, "title" to encTitle,
"content" to capsule.content, "content" to encContent,
"promptUsed" to capsule.promptUsed, "promptUsed" to encPrompt,
"unlockAt" to capsule.unlockAt, "unlockAt" to capsule.unlockAt,
"createdAt" to capsule.createdAt, "createdAt" to capsule.createdAt,
"status" to "sealed" "status" to "sealed"
@ -64,19 +93,7 @@ class FirestoreCapsuleDataSource @Inject constructor(private val db: FirebaseFir
.await() .await()
.documents .documents
.mapNotNull { doc -> .mapNotNull { doc ->
runCatching { runCatching { mapToCapsule(doc, coupleId) }.getOrNull()
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()
} }
suspend fun unlockCapsule(coupleId: String, capsuleId: String) { 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.core.navigation.AppRoute
import app.closer.data.challenges.ChallengesCatalog import app.closer.data.challenges.ChallengesCatalog
import app.closer.data.remote.FirestoreChallengeDataSource import app.closer.data.remote.FirestoreChallengeDataSource
import app.closer.domain.ChallengeStateMachine
import app.closer.domain.model.ChallengeProgressState 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.model.ConnectionChallenge
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.CoupleRepository
import java.time.LocalDate
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -76,6 +81,7 @@ data class ChallengesUiState(
val phase: ChallengesPhase = ChallengesPhase.LOADING, val phase: ChallengesPhase = ChallengesPhase.LOADING,
val activeChallenge: ConnectionChallenge? = null, val activeChallenge: ConnectionChallenge? = null,
val progress: ChallengeProgressState? = null, val progress: ChallengeProgressState? = null,
val challengeState: ChallengeState? = null,
val coupleId: String? = null, val coupleId: String? = null,
val userId: String? = null, val userId: String? = null,
val partnerId: String? = null, val partnerId: String? = null,
@ -144,6 +150,13 @@ class ConnectionChallengesViewModel @Inject constructor(
progressJob = viewModelScope.launch { progressJob = viewModelScope.launch {
challengeDataSource.observeProgress(coupleId, challenge.id, userId, partnerId) challengeDataSource.observeProgress(coupleId, challenge.id, userId, partnerId)
.collect { progress -> .collect { progress ->
val state = ChallengeStateMachine.compute(
ChallengeStateInput(
challenge = challenge,
progress = progress,
today = LocalDate.now()
)
)
_uiState.update { _uiState.update {
it.copy( it.copy(
phase = ChallengesPhase.ACTIVE, phase = ChallengesPhase.ACTIVE,
@ -151,13 +164,11 @@ class ConnectionChallengesViewModel @Inject constructor(
userId = userId, userId = userId,
partnerId = partnerId, partnerId = partnerId,
activeChallenge = challenge, activeChallenge = challenge,
progress = progress progress = progress,
challengeState = state
) )
} }
// Auto-complete challenge when all days jointly done. if (state.isComplete && progress.status == "active") {
if (progress.jointCompletedDays.size == challenge.durationDays &&
progress.status == "active"
) {
runCatching { challengeDataSource.finishChallenge(coupleId, challenge.id) } runCatching { challengeDataSource.finishChallenge(coupleId, challenge.id) }
} }
} }
@ -230,6 +241,7 @@ fun ConnectionChallengesScreen(
ChallengesPhase.ACTIVE -> ChallengesActiveScreen( ChallengesPhase.ACTIVE -> ChallengesActiveScreen(
challenge = state.activeChallenge!!, challenge = state.activeChallenge!!,
progress = state.progress ?: ChallengeProgressState(), progress = state.progress ?: ChallengeProgressState(),
challengeState = state.challengeState,
onBack = { onNavigate(AppRoute.PLAY) }, onBack = { onNavigate(AppRoute.PLAY) },
onMarkComplete = { viewModel.markTodayComplete() } onMarkComplete = { viewModel.markTodayComplete() }
) )
@ -398,17 +410,17 @@ private fun ChallengePickCard(
private fun ChallengesActiveScreen( private fun ChallengesActiveScreen(
challenge: ConnectionChallenge, challenge: ConnectionChallenge,
progress: ChallengeProgressState, progress: ChallengeProgressState,
challengeState: ChallengeState?,
onBack: () -> Unit, onBack: () -> Unit,
onMarkComplete: () -> Unit onMarkComplete: () -> Unit
) { ) {
val alreadyDoneToday = progress.myNextDay > (progress.myCompletedDays.maxOrNull() ?: 0) + 1 || val cs = challengeState
progress.myCompletedDays.contains(progress.myNextDay - 1) val isComplete = cs?.isComplete == true || cs?.state == ChallengeStatus.CHALLENGE_COMPLETE
val currentDay = cs?.currentDay ?: progress.myNextDay.coerceAtMost(challenge.durationDays)
// Has current user completed today? val canAdvance = cs?.canAdvance ?: true
val todayDone = progress.myCompletedDays.contains(progress.myNextDay.coerceAtMost(challenge.durationDays)) || val stateCopy = cs?.copy ?: ""
progress.myNextDay > challenge.durationDays val ctaLabel: String? = cs?.cta
val missedDay = cs?.missedDate
val allComplete = progress.isComplete || progress.jointCompletedDays.size == challenge.durationDays
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
@ -419,7 +431,6 @@ private fun ChallengesActiveScreen(
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
item { item {
// Header
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@ -441,13 +452,12 @@ private fun ChallengesActiveScreen(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
// Streak badge if (progress.jointStreak > 0 && !isComplete) {
if (progress.jointStreak > 0) {
Surface( Surface(
shape = RoundedCornerShape(999.dp), shape = RoundedCornerShape(999.dp),
color = CloserPalette.PurpleDeep.copy(alpha = 0.12f) color = CloserPalette.PurpleDeep.copy(alpha = 0.12f)
@ -465,22 +475,44 @@ private fun ChallengesActiveScreen(
} }
item { item {
// Day tracker strip
DayTrackerStrip( DayTrackerStrip(
totalDays = challenge.durationDays, totalDays = challenge.durationDays,
myCompletedDays = progress.myCompletedDays, myCompletedDays = progress.myCompletedDays,
partnerCompletedDays = progress.partnerCompletedDays, partnerCompletedDays = progress.partnerCompletedDays,
currentDay = progress.myNextDay currentDay = currentDay
) )
} }
if (!allComplete) { // Missed-day banner
val displayDay = progress.myNextDay.coerceAtMost(challenge.durationDays) if (missedDay != null && !isComplete) {
val dayPrompt = challenge.days.getOrNull(displayDay - 1) 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) { if (dayPrompt != null) {
item { item {
// Today's prompt card
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
@ -496,7 +528,7 @@ private fun ChallengesActiveScreen(
color = CloserPalette.PurpleDeep.copy(alpha = 0.10f) color = CloserPalette.PurpleDeep.copy(alpha = 0.10f)
) { ) {
Text( Text(
text = "Day $displayDay", text = "Day $currentDay",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = CloserPalette.PurpleDeep, color = CloserPalette.PurpleDeep,
@ -520,60 +552,71 @@ private fun ChallengesActiveScreen(
} }
} }
item { // State machine copy — describes current state (waiting, partner done, etc.)
// Partner status row if (stateCopy.isNotBlank()) {
val partnerDoneToday = progress.partnerCompletedDays.contains(displayDay) item {
Surface( val isWaiting = cs?.state == ChallengeStatus.WAITING_FOR_PARTNER
shape = RoundedCornerShape(16.dp), val isBothDone = cs?.state == ChallengeStatus.BOTH_COMPLETED_TODAY
color = if (partnerDoneToday) Surface(
CloserPalette.Evergreen.copy(alpha = 0.10f) shape = RoundedCornerShape(16.dp),
else color = when {
MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) isBothDone -> CloserPalette.Evergreen.copy(alpha = 0.10f)
) { isWaiting -> CloserPalette.PurpleDeep.copy(alpha = 0.07f)
Row( else -> MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
modifier = Modifier }
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
if (partnerDoneToday) { Row(
Icon(Icons.Filled.Check, contentDescription = null, tint = CloserPalette.Evergreen, modifier = Modifier.size(18.dp)) modifier = Modifier
Text("Partner completed today", style = MaterialTheme.typography.bodySmall, color = CloserPalette.Evergreen, fontWeight = FontWeight.Medium) .fillMaxWidth()
} else { .padding(14.dp),
Text("", style = MaterialTheme.typography.bodySmall) verticalAlignment = Alignment.CenterVertically,
Text("Waiting for your partner", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 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 { if (ctaLabel != null) {
// CTA item {
val iDoneToday = progress.myCompletedDays.contains(displayDay) Button(
Button( onClick = onMarkComplete,
onClick = onMarkComplete, enabled = canAdvance,
enabled = !iDoneToday, modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .heightIn(min = 54.dp),
.heightIn(min = 54.dp), shape = RoundedCornerShape(18.dp),
shape = RoundedCornerShape(18.dp), colors = ButtonDefaults.buttonColors(
colors = ButtonDefaults.buttonColors( containerColor = CloserPalette.PurpleDeep,
containerColor = CloserPalette.PurpleDeep, disabledContainerColor = CloserPalette.PurpleDeep.copy(alpha = 0.35f)
disabledContainerColor = CloserPalette.PurpleDeep.copy(alpha = 0.35f) )
) ) {
) { Text(
Text( text = ctaLabel,
text = if (iDoneToday) "Done for today ✓" else "I did it today", style = MaterialTheme.typography.labelLarge,
style = MaterialTheme.typography.labelLarge, color = Color.White
color = Color.White )
) }
} }
} }
} }
} else { } else {
item { item {
// Completion state
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
@ -587,7 +630,7 @@ private fun ChallengesActiveScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text("🎉", style = MaterialTheme.typography.displaySmall) Text(cs?.badge ?: "🏅", style = MaterialTheme.typography.displaySmall)
Text( Text(
text = "Challenge complete!", text = "Challenge complete!",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
@ -595,7 +638,7 @@ private fun ChallengesActiveScreen(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center

View File

@ -54,6 +54,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
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.core.billing.EntitlementChecker
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.data.remote.FirestoreDesireSyncDataSource import app.closer.data.remote.FirestoreDesireSyncDataSource
import android.content.Context import android.content.Context
@ -116,7 +117,8 @@ class DesireSyncViewModel @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager, private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource private val dataSource: FirestoreDesireSyncDataSource,
private val entitlementChecker: EntitlementChecker
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState()) private val _uiState = MutableStateFlow(DesireSyncUiState())
@ -137,6 +139,10 @@ class DesireSyncViewModel @Inject constructor(
private fun load() { private fun load() {
viewModelScope.launch { viewModelScope.launch {
if (!entitlementChecker.hasPremium()) {
_uiState.update { it.copy(navigateTo = AppRoute.PAYWALL) }
return@launch
}
val uid = gameSessionManager.currentUserId val uid = gameSessionManager.currentUserId
?: return@launch fail("You need to be signed in to play.") ?: return@launch fail("You need to be signed in to play.")
val couple = gameSessionManager.getCoupleForUser(uid) 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.closerCardColor
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding 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.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import app.closer.ui.components.CloserHeartLoader 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.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.R
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory import app.closer.domain.model.QuestionCategory
@ -58,13 +65,16 @@ fun QuestionCategoryScreen(
QuestionCategoryContent( QuestionCategoryContent(
categoryId = categoryId, categoryId = categoryId,
state = state, state = state,
onQuestionSelected = { question -> onBack = { onNavigate("back") },
val coupleId = state.coupleId onPickPrompt = {
if (coupleId != null) { val question = state.questions.randomOrNull()
onNavigate(AppRoute.questionThread(coupleId, question.id)) if (question != null) {
} else { val coupleId = state.coupleId
// Discussing requires a paired partner; send unpaired users to invite one. if (coupleId != null) {
onNavigate(AppRoute.CREATE_INVITE) onNavigate(AppRoute.questionThread(coupleId, question.id))
} else {
onNavigate(AppRoute.CREATE_INVITE)
}
} }
} }
) )
@ -74,21 +84,15 @@ fun QuestionCategoryScreen(
private fun QuestionCategoryContent( private fun QuestionCategoryContent(
categoryId: String, categoryId: String,
state: QuestionCategoryUiState, state: QuestionCategoryUiState,
onQuestionSelected: (Question) -> Unit onBack: () -> Unit,
onPickPrompt: () -> Unit
) { ) {
var selectedType by remember { mutableStateOf<String?>(null) } val title = state.category?.displayName ?: categoryId.displayCategoryName()
val visibleQuestions = remember(state.questions, selectedType) {
state.questions.filter { question ->
selectedType == null || question.type == selectedType
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(closerBackgroundBrush())
closerBackgroundBrush()
)
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
@ -96,16 +100,35 @@ private fun QuestionCategoryContent(
.safeDrawingPadding() .safeDrawingPadding()
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 20.dp), .padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
item { item {
val title = state.category?.displayName Row(
?: categoryId.displayCategoryName() 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( CategoryHero(
title = title, title = title,
category = state.category, category = state.category,
questionCount = state.questions.size, 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.isLoading -> item { CategoryLoadingCard() }
state.error != null -> item { state.error != null -> item {
CategoryMessageCard( CategoryMessageCard(
title = "Category paused", title = "Pack unavailable",
message = state.error message = state.error
) )
} }
state.questions.isEmpty() -> item { else -> item {
CategoryMessageCard( Button(
title = "No prompts found", onClick = onPickPrompt,
message = "No prompts are available for ${categoryId.displayCategoryName()} right now." enabled = state.questions.isNotEmpty(),
) modifier = Modifier
} .fillMaxWidth()
else -> { .heightIn(min = 54.dp),
item { shape = RoundedCornerShape(18.dp),
CategoryFilters( colors = ButtonDefaults.buttonColors(
questions = state.questions, containerColor = Color(0xFF56306F),
selectedType = selectedType, contentColor = Color.White,
onTypeSelected = { selectedType = it } 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, title: String,
category: QuestionCategory?, category: QuestionCategory?,
questionCount: Int, questionCount: Int,
modifier: Modifier = Modifier isLoading: Boolean = false
) { ) {
Column( Column(
modifier = modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Row( Row(
@ -192,182 +204,80 @@ private fun CategoryHero(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = category?.description text = category?.description ?: "Prompts for this kind of conversation.",
?: "Browse prompts for this kind of conversation.",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3, maxLines = 4,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
} }
Row( if (!isLoading) {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier
.horizontalScroll(rememberScrollState()), .fillMaxWidth()
horizontalArrangement = Arrangement.spacedBy(8.dp) .horizontalScroll(rememberScrollState()),
) { horizontalArrangement = Arrangement.spacedBy(8.dp)
CategoryPill("$questionCount ${if (questionCount == 1) "prompt" else "prompts"}", emphasis = true) ) {
category?.access?.let { CategoryPill(it.displayCategoryName()) } CategoryPill("$questionCount ${if (questionCount == 1) "prompt" else "prompts"}", emphasis = true)
category?.iconName category?.access?.let { access ->
?.takeIf { it.isNotBlank() } CategoryPill(
?.let { it.displayCategoryName() } when (access) {
?.takeIf { it != "Question" } "premium" -> "Premium"
?.let { CategoryPill(it) } "mixed" -> "Some free"
} else -> "Free"
} }
} )
@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")
} }
} }
} }
} }
} }
@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 @Composable
private fun CategoryPill( private fun CategoryPill(
label: String, label: String,
@ -467,6 +377,7 @@ fun QuestionCategoryScreenPreview() {
) )
) )
), ),
onQuestionSelected = {} onBack = {},
onPickPrompt = {}
) )
} }

View File

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

View File

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