From 3f5d7a5cc183fca6fcf556748da00b529288c8ed Mon Sep 17 00:00:00 2001 From: null Date: Mon, 22 Jun 2026 21:19:19 -0500 Subject: [PATCH] feat: nav, capsule data source, challenges, desire sync, question category, wheel history + viewmodel --- .../closer/core/navigation/AppNavigation.kt | 6 +- .../data/remote/FirestoreCapsuleDataSource.kt | 77 ++-- .../challenges/ConnectionChallengesScreen.kt | 181 +++++---- .../closer/ui/desiresync/DesireSyncScreen.kt | 8 +- .../ui/questions/QuestionCategoryScreen.kt | 365 +++++++----------- .../app/closer/ui/wheel/WheelHistoryScreen.kt | 8 +- .../closer/ui/wheel/WheelHistoryViewModel.kt | 8 +- 7 files changed, 315 insertions(+), 338 deletions(-) diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 47c70f59..77a34452 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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, diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt index 025208a5..a711d977 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt @@ -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> = 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) { diff --git a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt index 70bdca8e..c3e89f09 100644 --- a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt +++ b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index 6f0fbf8d..7fc1731b 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -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) diff --git a/app/src/main/java/app/closer/ui/questions/QuestionCategoryScreen.kt b/app/src/main/java/app/closer/ui/questions/QuestionCategoryScreen.kt index bd75ecc5..064cbe34 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionCategoryScreen.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionCategoryScreen.kt @@ -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(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, - 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 = {} ) } diff --git a/app/src/main/java/app/closer/ui/wheel/WheelHistoryScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelHistoryScreen.kt index b6abdbd5..7f098164 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelHistoryScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelHistoryScreen.kt @@ -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), diff --git a/app/src/main/java/app/closer/ui/wheel/WheelHistoryViewModel.kt b/app/src/main/java/app/closer/ui/wheel/WheelHistoryViewModel.kt index eaefcbb5..ab0144dd 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelHistoryViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelHistoryViewModel.kt @@ -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 = 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 = _uiState.asStateFlow() + private val _uiState = MutableStateFlow(GameHistoryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() init { entitlementChecker.isPremium()