feat: capsule/challenge data sources, game screens, wheel history + viewmodel

This commit is contained in:
null 2026-06-22 21:38:18 -05:00
parent 17403b1a75
commit c56dd53edd
9 changed files with 228 additions and 26 deletions

View File

@ -26,13 +26,23 @@ class FirestoreCapsuleDataSource @Inject constructor(
.document(coupleId) .document(coupleId)
.collection(FirestoreCollections.Couples.CAPSULES) .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? { private fun decryptField(value: String?, coupleId: String): String? {
if (value == null) return null 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) } return runCatching { fieldEncryptor.decrypt(value, aead, coupleId) }
.getOrElse { e -> .getOrElse { e ->
Log.w(TAG, "decrypt failed, returning raw: ${e.message}") Log.w(TAG, "decrypt failed: ${e.message}")
value null
} }
} }
@ -44,8 +54,8 @@ class FirestoreCapsuleDataSource @Inject constructor(
id = doc.id, id = doc.id,
coupleId = coupleId, coupleId = coupleId,
authorId = doc.getString("authorId") ?: "", authorId = doc.getString("authorId") ?: "",
title = decryptField(rawTitle, coupleId) ?: rawTitle, title = decryptField(rawTitle, coupleId) ?: "— Encrypted —",
content = decryptField(rawContent, coupleId) ?: rawContent, content = decryptField(rawContent, coupleId) ?: "— Encrypted —",
promptUsed = decryptField(rawPrompt, coupleId), promptUsed = decryptField(rawPrompt, coupleId),
unlockAt = doc.getLong("unlockAt") ?: 0L, unlockAt = doc.getLong("unlockAt") ?: 0L,
createdAt = doc.getLong("createdAt") ?: 0L, createdAt = doc.getLong("createdAt") ?: 0L,
@ -65,11 +75,17 @@ class FirestoreCapsuleDataSource @Inject constructor(
awaitClose { reg.remove() } 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 { suspend fun createCapsule(capsule: TimeCapsule): String {
val aead = encryptionManager.aeadFor(capsule.coupleId) val aead = encryptionManager.aeadFor(capsule.coupleId)
val encTitle = aead?.let { fieldEncryptor.encrypt(capsule.title, it, capsule.coupleId) } ?: capsule.title ?: throw IllegalStateException("Encryption key not ready. Please try again in a moment.")
val encContent = aead?.let { fieldEncryptor.encrypt(capsule.content, it, capsule.coupleId) } ?: capsule.content val encTitle = fieldEncryptor.encrypt(capsule.title, aead, capsule.coupleId)
val encPrompt = aead?.let { fieldEncryptor.encryptNullable(capsule.promptUsed, it, capsule.coupleId) } ?: capsule.promptUsed val encContent = fieldEncryptor.encrypt(capsule.content, aead, capsule.coupleId)
val encPrompt = fieldEncryptor.encryptNullable(capsule.promptUsed, aead, capsule.coupleId)
val ref = col(capsule.coupleId).document() val ref = col(capsule.coupleId).document()
ref.set( ref.set(

View File

@ -24,6 +24,18 @@ class FirestoreChallengeDataSource @Inject constructor(private val db: FirebaseF
.document(coupleId) .document(coupleId)
.collection(FirestoreCollections.Couples.CHALLENGES) .collection(FirestoreCollections.Couples.CHALLENGES)
suspend fun getCompletedChallengeIds(coupleId: String): List<Pair<String, Long>> =
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? = suspend fun getActiveChallengeId(coupleId: String): String? =
challengesCol(coupleId) challengesCol(coupleId)
.whereEqualTo("status", "active") .whereEqualTo("status", "active")

View File

@ -108,7 +108,7 @@ class ConnectionChallengesViewModel @Inject constructor(
private fun load() { private fun load() {
viewModelScope.launch { viewModelScope.launch {
val uid = authRepository.currentUserId ?: run { val uid = authRepository.currentUserId ?: run {
_uiState.update { it.copy(phase = ChallengesPhase.PICK) } _uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
return@launch return@launch
} }
val couple = coupleRepository.getCoupleForUser(uid) ?: run { val couple = coupleRepository.getCoupleForUser(uid) ?: run {

View File

@ -265,7 +265,11 @@ class DesireSyncViewModel @Inject constructor(
val sId = sessionId ?: return val sId = sessionId ?: return
val uid = userId ?: return val uid = userId ?: return
runCatching { dataSource.submitAnswers(cId, sId, uid, answers) } 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. // The observer flips WAITING → REVEAL once the partner's answers land.
} }

View File

@ -295,7 +295,11 @@ class HowWellViewModel @Inject constructor(
val sId = sessionId ?: return val sId = sessionId ?: return
val uid = userId ?: return val uid = userId ?: return
runCatching { dataSource.submitAnswers(cId, sId, uid, myAnswers.toList()) } 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. // The observer flips WAITING → COMPLETE once the partner's answers land.
} }

View File

@ -227,9 +227,10 @@ class MemoryLaneViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
runCatching { capsuleDataSource.createCapsule(capsule) } runCatching { capsuleDataSource.createCapsule(capsule) }
.onSuccess { _uiState.update { it.copy(isSaving = false, phase = MemoryLanePhase.LIST) } } .onSuccess { _uiState.update { it.copy(isSaving = false, phase = MemoryLanePhase.LIST) } }
.onFailure { .onFailure { e ->
Log.w(TAG, "Could not save capsule", it) Log.w(TAG, "Could not save capsule", e)
_uiState.update { s -> s.copy(isSaving = false, error = "Could not save. Try again.") } 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) }
} }
} }
} }

View File

@ -308,7 +308,11 @@ class ThisOrThatViewModel @Inject constructor(
val sId = sessionId ?: return val sId = sessionId ?: return
val uid = userId ?: return val uid = userId ?: return
runCatching { dataSource.submitAnswers(cId, sId, uid, answers) } 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 // The observer flips WAITING → REVEAL once the partner's answers land
// (or right away, if they finished first). // (or right away, if they finished first).
} }

View File

@ -47,6 +47,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType import app.closer.domain.model.GameType
import app.closer.domain.model.QuestionSession import app.closer.domain.model.QuestionSession
import app.closer.domain.model.TimeCapsule
import app.closer.ui.components.EmptyState import app.closer.ui.components.EmptyState
import app.closer.ui.components.ErrorState import app.closer.ui.components.ErrorState
import app.closer.ui.components.LoadingState import app.closer.ui.components.LoadingState
@ -100,30 +101,49 @@ fun GameHistoryScreen(
) )
} }
val hasAny = state.sessions.isNotEmpty() || state.completedChallenges.isNotEmpty() || state.unlockedCapsules.isNotEmpty()
when { when {
!state.hasPremium -> item { !state.hasPremium -> item {
GameHistoryLockedCard(onUnlock = { onNavigate(AppRoute.PAYWALL) }) 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 { state.error != null -> item {
ErrorState( ErrorState(
message = state.error!!, message = state.error!!,
onRetry = viewModel::load onRetry = viewModel::load
) )
} }
state.sessions.isEmpty() -> item { !hasAny -> item {
EmptyState( EmptyState(
title = "No games played yet", 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", actionLabel = "Play a game",
onAction = { onNavigate(AppRoute.PLAY) } onAction = { onNavigate(AppRoute.PLAY) }
) )
} }
else -> items(state.sessions, key = { it.id }) { session -> else -> {
WheelSessionCard( if (state.sessions.isNotEmpty()) {
session = session, item { HistorySectionHeader("Game Sessions") }
onClick = { onNavigate(sessionReplayRoute(session)) } 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) { private fun sessionTitle(session: QuestionSession): String = when (session.gameType) {
GameType.THIS_OR_THAT -> "This or That" GameType.THIS_OR_THAT -> "This or That"
GameType.HOW_WELL -> "How Well Do You Know Me" GameType.HOW_WELL -> "How Well Do You Know Me"

View File

@ -3,11 +3,16 @@ package app.closer.ui.wheel
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.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.QuestionSession
import app.closer.domain.model.TimeCapsule
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 app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.repository.QuestionSessionRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -17,9 +22,18 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
data class CompletedChallengeEntry(
val challengeId: String,
val title: String,
val emoji: String,
val startedAt: Long
)
data class GameHistoryUiState( data class GameHistoryUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val sessions: List<QuestionSession> = emptyList(), val sessions: List<QuestionSession> = emptyList(),
val completedChallenges: List<CompletedChallengeEntry> = emptyList(),
val unlockedCapsules: List<TimeCapsule> = emptyList(),
val hasPremium: Boolean = false, val hasPremium: Boolean = false,
val error: String? = null val error: String? = null
) )
@ -29,6 +43,8 @@ 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,
private val challengeDataSource: FirestoreChallengeDataSource,
private val capsuleDataSource: FirestoreCapsuleDataSource,
entitlementChecker: EntitlementChecker entitlementChecker: EntitlementChecker
) : ViewModel() { ) : ViewModel() {
@ -53,12 +69,48 @@ class GameHistoryViewModel @Inject constructor(
_uiState.update { it.copy(isLoading = false) } _uiState.update { it.copy(isLoading = false) }
return@launch 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 -> .onSuccess { sessions ->
_uiState.update { it.copy(isLoading = false, sessions = sessions) } _uiState.update {
it.copy(
isLoading = false,
sessions = sessions,
completedChallenges = completedChallenges,
unlockedCapsules = unlockedCapsules
)
}
} }
.onFailure { e -> .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."
)
}
} }
} }
} }