From c56dd53eddc6a4dcee0fa5a39ffbeea250aaa94b Mon Sep 17 00:00:00 2001 From: null Date: Mon, 22 Jun 2026 21:38:18 -0500 Subject: [PATCH] feat: capsule/challenge data sources, game screens, wheel history + viewmodel --- .../data/remote/FirestoreCapsuleDataSource.kt | 32 +++-- .../remote/FirestoreChallengeDataSource.kt | 12 ++ .../challenges/ConnectionChallengesScreen.kt | 2 +- .../closer/ui/desiresync/DesireSyncScreen.kt | 6 +- .../app/closer/ui/howwell/HowWellScreen.kt | 6 +- .../closer/ui/memorylane/MemoryLaneScreen.kt | 7 +- .../closer/ui/thisorthat/ThisOrThatScreen.kt | 6 +- .../app/closer/ui/wheel/WheelHistoryScreen.kt | 125 ++++++++++++++++-- .../closer/ui/wheel/WheelHistoryViewModel.kt | 58 +++++++- 9 files changed, 228 insertions(+), 26 deletions(-) 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 a711d977..2955f65f 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt @@ -26,13 +26,23 @@ class FirestoreCapsuleDataSource @Inject constructor( .document(coupleId) .collection(FirestoreCollections.Couples.CAPSULES) + /** + * Decrypts a field value. + * - Plaintext (unencrypted legacy value): returned as-is for backward compat. + * - Encrypted but key unavailable: returns null so the UI can show a placeholder. + * - Decryption failure: returns null rather than leaking a garbled ciphertext. + */ private fun decryptField(value: String?, coupleId: String): String? { if (value == null) return null - val aead = encryptionManager.aeadFor(coupleId) + if (!fieldEncryptor.isEncrypted(value)) return value // legacy plaintext + val aead = encryptionManager.aeadFor(coupleId) ?: run { + Log.w(TAG, "key unavailable for $coupleId, masking encrypted field") + return null + } return runCatching { fieldEncryptor.decrypt(value, aead, coupleId) } .getOrElse { e -> - Log.w(TAG, "decrypt failed, returning raw: ${e.message}") - value + Log.w(TAG, "decrypt failed: ${e.message}") + null } } @@ -44,8 +54,8 @@ class FirestoreCapsuleDataSource @Inject constructor( id = doc.id, coupleId = coupleId, authorId = doc.getString("authorId") ?: "", - title = decryptField(rawTitle, coupleId) ?: rawTitle, - content = decryptField(rawContent, coupleId) ?: rawContent, + title = decryptField(rawTitle, coupleId) ?: "— Encrypted —", + content = decryptField(rawContent, coupleId) ?: "— Encrypted —", promptUsed = decryptField(rawPrompt, coupleId), unlockAt = doc.getLong("unlockAt") ?: 0L, createdAt = doc.getLong("createdAt") ?: 0L, @@ -65,11 +75,17 @@ class FirestoreCapsuleDataSource @Inject constructor( awaitClose { reg.remove() } } + /** + * Writes a capsule. Requires the couple encryption key to be available — throws + * [IllegalStateException] if the key is missing so the caller can surface an error + * rather than silently storing plaintext. + */ 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 + ?: throw IllegalStateException("Encryption key not ready. Please try again in a moment.") + val encTitle = fieldEncryptor.encrypt(capsule.title, aead, capsule.coupleId) + val encContent = fieldEncryptor.encrypt(capsule.content, aead, capsule.coupleId) + val encPrompt = fieldEncryptor.encryptNullable(capsule.promptUsed, aead, capsule.coupleId) val ref = col(capsule.coupleId).document() ref.set( diff --git a/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt index bac02f9d..a94dbba9 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt @@ -24,6 +24,18 @@ class FirestoreChallengeDataSource @Inject constructor(private val db: FirebaseF .document(coupleId) .collection(FirestoreCollections.Couples.CHALLENGES) + suspend fun getCompletedChallengeIds(coupleId: String): List> = + challengesCol(coupleId) + .whereEqualTo("status", "completed") + .get() + .await() + .documents + .mapNotNull { doc -> + val id = doc.getString("challengeId") ?: return@mapNotNull null + val startedAt = doc.getLong("startedAt") ?: 0L + Pair(id, startedAt) + } + suspend fun getActiveChallengeId(coupleId: String): String? = challengesCol(coupleId) .whereEqualTo("status", "active") 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 bc3d232c..eec6057a 100644 --- a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt +++ b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt @@ -108,7 +108,7 @@ class ConnectionChallengesViewModel @Inject constructor( private fun load() { viewModelScope.launch { val uid = authRepository.currentUserId ?: run { - _uiState.update { it.copy(phase = ChallengesPhase.PICK) } + _uiState.update { it.copy(navigateTo = AppRoute.PLAY) } return@launch } val couple = coupleRepository.getCoupleForUser(uid) ?: run { 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 fe01dbc2..e7e0180b 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -265,7 +265,11 @@ class DesireSyncViewModel @Inject constructor( val sId = sessionId ?: return val uid = userId ?: return runCatching { dataSource.submitAnswers(cId, sId, uid, answers) } - .onFailure { Log.w(TAG, "Could not submit answers", it) } + .onFailure { e -> + submitted = false + Log.w(TAG, "Could not submit answers", e) + _uiState.update { it.copy(phase = DesireSyncPhase.ERROR, error = "Couldn't save your answers. Check your connection and go back to try again.") } + } // The observer flips WAITING → REVEAL once the partner's answers land. } diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index b09720e9..e526b3bd 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -295,7 +295,11 @@ class HowWellViewModel @Inject constructor( val sId = sessionId ?: return val uid = userId ?: return runCatching { dataSource.submitAnswers(cId, sId, uid, myAnswers.toList()) } - .onFailure { Log.w(TAG, "Could not submit answers", it) } + .onFailure { e -> + submitted = false + Log.w(TAG, "Could not submit answers", e) + _uiState.update { it.copy(phase = HowWellPhase.ERROR, error = "Couldn't save your answers. Check your connection and go back to try again.") } + } // The observer flips WAITING → COMPLETE once the partner's answers land. } diff --git a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt index 798ca1b0..00426e98 100644 --- a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt +++ b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt @@ -227,9 +227,10 @@ class MemoryLaneViewModel @Inject constructor( viewModelScope.launch { runCatching { capsuleDataSource.createCapsule(capsule) } .onSuccess { _uiState.update { it.copy(isSaving = false, phase = MemoryLanePhase.LIST) } } - .onFailure { - Log.w(TAG, "Could not save capsule", it) - _uiState.update { s -> s.copy(isSaving = false, error = "Could not save. Try again.") } + .onFailure { e -> + Log.w(TAG, "Could not save capsule", e) + val msg = if (e is IllegalStateException) e.message ?: "Encryption not ready. Try again." else "Could not save. Check your connection." + _uiState.update { s -> s.copy(isSaving = false, error = msg) } } } } diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index bf67c8b6..d96140ea 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -308,7 +308,11 @@ class ThisOrThatViewModel @Inject constructor( val sId = sessionId ?: return val uid = userId ?: return runCatching { dataSource.submitAnswers(cId, sId, uid, answers) } - .onFailure { Log.w(TAG, "Could not submit answers", it) } + .onFailure { e -> + submitted = false + Log.w(TAG, "Could not submit answers", e) + _uiState.update { it.copy(phase = TotPhase.ERROR, error = "Couldn't save your answers. Check your connection and go back to try again.") } + } // The observer flips WAITING → REVEAL once the partner's answers land // (or right away, if they finished first). } 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 7f098164..8f7e77c0 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelHistoryScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelHistoryScreen.kt @@ -47,6 +47,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import app.closer.core.navigation.AppRoute import app.closer.domain.model.GameType import app.closer.domain.model.QuestionSession +import app.closer.domain.model.TimeCapsule import app.closer.ui.components.EmptyState import app.closer.ui.components.ErrorState import app.closer.ui.components.LoadingState @@ -100,30 +101,49 @@ fun GameHistoryScreen( ) } + val hasAny = state.sessions.isNotEmpty() || state.completedChallenges.isNotEmpty() || state.unlockedCapsules.isNotEmpty() + when { !state.hasPremium -> item { GameHistoryLockedCard(onUnlock = { onNavigate(AppRoute.PAYWALL) }) } - state.isLoading -> item { LoadingState(message = "Loading your sessions…") } + state.isLoading -> item { LoadingState(message = "Loading your games…") } state.error != null -> item { ErrorState( message = state.error!!, onRetry = viewModel::load ) } - state.sessions.isEmpty() -> item { + !hasAny -> item { EmptyState( title = "No games played yet", - body = "Finish a round of This or That, How Well, Desire Sync, or Spin the Wheel — your results will show up here so you can revisit them together.", + body = "Finish a round of This or That, How Well, Desire Sync, Spin the Wheel, a Connection Challenge, or Memory Lane — your results will show up here.", actionLabel = "Play a game", onAction = { onNavigate(AppRoute.PLAY) } ) } - else -> items(state.sessions, key = { it.id }) { session -> - WheelSessionCard( - session = session, - onClick = { onNavigate(sessionReplayRoute(session)) } - ) + else -> { + if (state.sessions.isNotEmpty()) { + item { HistorySectionHeader("Game Sessions") } + items(state.sessions, key = { it.id }) { session -> + WheelSessionCard( + session = session, + onClick = { onNavigate(sessionReplayRoute(session)) } + ) + } + } + if (state.completedChallenges.isNotEmpty()) { + item { HistorySectionHeader("Completed Challenges") } + items(state.completedChallenges, key = { it.challengeId }) { entry -> + ChallengeHistoryCard(entry) + } + } + if (state.unlockedCapsules.isNotEmpty()) { + item { HistorySectionHeader("Memory Lane") } + items(state.unlockedCapsules, key = { it.id }) { capsule -> + CapsuleHistoryCard(capsule) + } + } } } } @@ -169,6 +189,95 @@ private fun WheelSessionCard(session: QuestionSession, onClick: () -> Unit = {}) } } +@Composable +private fun HistorySectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp) + ) +} + +@Composable +private fun ChallengeHistoryCard(entry: CompletedChallengeEntry) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(entry.emoji, style = MaterialTheme.typography.headlineSmall) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = entry.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Challenge complete", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (entry.startedAt > 0L) { + Text( + text = SimpleDateFormat("d MMM", java.util.Locale.getDefault()).format(Date(entry.startedAt)), + style = MaterialTheme.typography.labelMedium, + color = Color(0xFFB98AF4) + ) + } + } + } +} + +@Composable +private fun CapsuleHistoryCard(capsule: TimeCapsule) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("💌", style = MaterialTheme.typography.headlineSmall) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = capsule.title.ifBlank { "Untitled capsule" }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "Memory Lane", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (capsule.unlockAt > 0L) { + Text( + text = SimpleDateFormat("d MMM", java.util.Locale.getDefault()).format(Date(capsule.unlockAt)), + style = MaterialTheme.typography.labelMedium, + color = Color(0xFFB98AF4) + ) + } + } + } +} + private fun sessionTitle(session: QuestionSession): String = when (session.gameType) { GameType.THIS_OR_THAT -> "This or That" GameType.HOW_WELL -> "How Well Do You Know Me" 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 ab0144dd..7b1eb5b7 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelHistoryViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelHistoryViewModel.kt @@ -3,11 +3,16 @@ package app.closer.ui.wheel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.core.billing.EntitlementChecker +import app.closer.data.challenges.ChallengesCatalog +import app.closer.data.remote.FirestoreCapsuleDataSource +import app.closer.data.remote.FirestoreChallengeDataSource import app.closer.domain.model.QuestionSession +import app.closer.domain.model.TimeCapsule import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.QuestionSessionRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,9 +22,18 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +data class CompletedChallengeEntry( + val challengeId: String, + val title: String, + val emoji: String, + val startedAt: Long +) + data class GameHistoryUiState( val isLoading: Boolean = false, val sessions: List = emptyList(), + val completedChallenges: List = emptyList(), + val unlockedCapsules: List = emptyList(), val hasPremium: Boolean = false, val error: String? = null ) @@ -29,6 +43,8 @@ class GameHistoryViewModel @Inject constructor( private val sessionRepository: QuestionSessionRepository, private val authRepository: AuthRepository, private val coupleRepository: CoupleRepository, + private val challengeDataSource: FirestoreChallengeDataSource, + private val capsuleDataSource: FirestoreCapsuleDataSource, entitlementChecker: EntitlementChecker ) : ViewModel() { @@ -53,12 +69,48 @@ class GameHistoryViewModel @Inject constructor( _uiState.update { it.copy(isLoading = false) } return@launch } - sessionRepository.getSessionsForCouple(couple.id) + val coupleId = couple.id + + val sessionsDeferred = async { sessionRepository.getSessionsForCouple(coupleId) } + val challengesDeferred = async { runCatching { challengeDataSource.getCompletedChallengeIds(coupleId) }.getOrElse { emptyList() } } + val capsulesDeferred = async { runCatching { capsuleDataSource.getCapsules(coupleId) }.getOrElse { emptyList() } } + + val sessionsResult = sessionsDeferred.await() + val completedChallengeIds = challengesDeferred.await() + val allCapsules = capsulesDeferred.await() + + val completedChallenges = completedChallengeIds.mapNotNull { (challengeId, startedAt) -> + val def = ChallengesCatalog.findById(challengeId) ?: return@mapNotNull null + CompletedChallengeEntry( + challengeId = challengeId, + title = def.title, + emoji = def.emoji, + startedAt = startedAt + ) + }.sortedByDescending { it.startedAt } + + val unlockedCapsules = allCapsules.filter { it.status == "unlocked" } + + sessionsResult .onSuccess { sessions -> - _uiState.update { it.copy(isLoading = false, sessions = sessions) } + _uiState.update { + it.copy( + isLoading = false, + sessions = sessions, + completedChallenges = completedChallenges, + unlockedCapsules = unlockedCapsules + ) + } } .onFailure { e -> - _uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't load your past games.") } + _uiState.update { + it.copy( + isLoading = false, + completedChallenges = completedChallenges, + unlockedCapsules = unlockedCapsules, + error = e.message ?: "Couldn't load your past games." + ) + } } } }