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