feat: nav, capsule data source, challenges, desire sync, question category, wheel history + viewmodel
This commit is contained in:
parent
2108d48914
commit
3f5d7a5cc1
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue